Compare commits
3 Commits
b7ce929c50
...
e0069fd8c6
| Author | SHA1 | Date | |
|---|---|---|---|
| e0069fd8c6 | |||
| fc2abda588 | |||
| 7da22da168 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ project-knowledge/.obsidian/cache/
|
|||||||
|
|
||||||
# AI Workspace local service runtime
|
# AI Workspace local service runtime
|
||||||
.aiw/runtime/
|
.aiw/runtime/
|
||||||
|
.aiw/indexes/
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ Install to `~/Applications/AIWorkspace.app`:
|
|||||||
apps/mac/AIWorkspace/scripts/package-app.sh --install
|
apps/mac/AIWorkspace/scripts/package-app.sh --install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Build a DMG
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/mac/AIWorkspace/scripts/build-dmg.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/mac/AIWorkspace/dist/AIWorkspace.dmg
|
||||||
|
```
|
||||||
|
|
||||||
|
The DMG contains `AIWorkspace.app` and an `Applications` shortcut for drag-and-drop installation.
|
||||||
|
|
||||||
One-step local install, optionally enabling start at login and opening the app:
|
One-step local install, optionally enabling start at login and opening the app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -36,7 +50,9 @@ apps/mac/AIWorkspace/scripts/install.sh --start-at-login --open
|
|||||||
|
|
||||||
## Start at login
|
## Start at login
|
||||||
|
|
||||||
After installing the app bundle:
|
Preferred: open the installed app and use the **Start at Login** toggle in the UI. The app uses macOS `SMAppService` for login item registration.
|
||||||
|
|
||||||
|
Development fallback after installing the app bundle:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
|
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
|
||||||
@@ -74,4 +90,5 @@ swift run --package-path apps/mac/AIWorkspace AIWorkspace
|
|||||||
|
|
||||||
- This is not yet packaged as a signed `.app` bundle.
|
- This is not yet packaged as a signed `.app` bundle.
|
||||||
- Start at login should be implemented later through a LaunchAgent or app login item.
|
- Start at login should be implemented later through a LaunchAgent or app login item.
|
||||||
|
- Start at Login is available in the app UI through `SMAppService`. The LaunchAgent scripts are retained as development fallback utilities.
|
||||||
- The app should remain a UI layer; service lifecycle remains in `scripts/aiw/services.py`.
|
- The app should remain a UI layer; service lifecycle remains in `scripts/aiw/services.py`.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ServiceManagement
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true)
|
private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true)
|
||||||
@@ -27,6 +28,7 @@ final class ServiceStatusModel: ObservableObject {
|
|||||||
@Published private(set) var report: StatusReport?
|
@Published private(set) var report: StatusReport?
|
||||||
@Published private(set) var lastError: String?
|
@Published private(set) var lastError: String?
|
||||||
@Published private(set) var lanIP: String?
|
@Published private(set) var lanIP: String?
|
||||||
|
@Published private(set) var loginItemStatus: SMAppService.Status = .notRegistered
|
||||||
@Published private(set) var isRefreshing = false
|
@Published private(set) var isRefreshing = false
|
||||||
|
|
||||||
let profile: String
|
let profile: String
|
||||||
@@ -53,12 +55,42 @@ final class ServiceStatusModel: ObservableObject {
|
|||||||
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
|
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
|
||||||
report = try JSONDecoder().decode(StatusReport.self, from: data)
|
report = try JSONDecoder().decode(StatusReport.self, from: data)
|
||||||
lanIP = await NetworkInfo.primaryLANIP()
|
lanIP = await NetworkInfo.primaryLANIP()
|
||||||
|
loginItemStatus = SMAppService.mainApp.status
|
||||||
lastError = nil
|
lastError = nil
|
||||||
} catch {
|
} catch {
|
||||||
lastError = String(describing: error)
|
lastError = String(describing: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var startAtLoginEnabled: Bool {
|
||||||
|
loginItemStatus == .enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var startAtLoginStatusText: String {
|
||||||
|
switch loginItemStatus {
|
||||||
|
case .enabled: "enabled"
|
||||||
|
case .notRegistered: "off"
|
||||||
|
case .notFound: "not found"
|
||||||
|
case .requiresApproval: "requires approval"
|
||||||
|
@unknown default: "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStartAtLogin(_ enabled: Bool) {
|
||||||
|
do {
|
||||||
|
if enabled {
|
||||||
|
try SMAppService.mainApp.register()
|
||||||
|
} else {
|
||||||
|
try SMAppService.mainApp.unregister()
|
||||||
|
}
|
||||||
|
loginItemStatus = SMAppService.mainApp.status
|
||||||
|
lastError = nil
|
||||||
|
} catch {
|
||||||
|
loginItemStatus = SMAppService.mainApp.status
|
||||||
|
lastError = "Start at Login: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func startProfile() {
|
func startProfile() {
|
||||||
runAction(["start", "--profile", profile])
|
runAction(["start", "--profile", profile])
|
||||||
}
|
}
|
||||||
@@ -219,6 +251,21 @@ struct ServiceMenuView: View {
|
|||||||
|
|
||||||
ActionButton(title: "Open Project Knowledge", systemImage: "books.vertical", action: model.openProjectKnowledge)
|
ActionButton(title: "Open Project Knowledge", systemImage: "books.vertical", action: model.openProjectKnowledge)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { model.startAtLoginEnabled },
|
||||||
|
set: { model.setStartAtLogin($0) }
|
||||||
|
)) {
|
||||||
|
Label("Start at Login", systemImage: "poweron")
|
||||||
|
}
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
|
||||||
|
Text("Login item: \(model.startAtLoginStatusText)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
HStack {
|
HStack {
|
||||||
if let error = model.lastError {
|
if let error = model.lastError {
|
||||||
|
|||||||
27
apps/mac/AIWorkspace/scripts/build-dmg.sh
Normal file
27
apps/mac/AIWorkspace/scripts/build-dmg.sh
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
APP_NAME="AIWorkspace"
|
||||||
|
DIST_DIR="$APP_ROOT/dist"
|
||||||
|
APP_BUNDLE="$DIST_DIR/$APP_NAME.app"
|
||||||
|
DMG_STAGING="$DIST_DIR/dmg-staging"
|
||||||
|
DMG_PATH="$DIST_DIR/$APP_NAME.dmg"
|
||||||
|
|
||||||
|
bash "$SCRIPT_DIR/package-app.sh"
|
||||||
|
|
||||||
|
rm -rf "$DMG_STAGING" "$DMG_PATH"
|
||||||
|
mkdir -p "$DMG_STAGING"
|
||||||
|
cp -R "$APP_BUNDLE" "$DMG_STAGING/$APP_NAME.app"
|
||||||
|
ln -s /Applications "$DMG_STAGING/Applications"
|
||||||
|
|
||||||
|
hdiutil create \
|
||||||
|
-volname "AI Workspace" \
|
||||||
|
-srcfolder "$DMG_STAGING" \
|
||||||
|
-ov \
|
||||||
|
-format UDZO \
|
||||||
|
"$DMG_PATH"
|
||||||
|
|
||||||
|
rm -rf "$DMG_STAGING"
|
||||||
|
echo "Built $DMG_PATH"
|
||||||
@@ -22,10 +22,10 @@ for arg in "$@"; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
"$SCRIPT_DIR/package-app.sh" --install
|
bash "$SCRIPT_DIR/package-app.sh" --install
|
||||||
|
|
||||||
if [[ "$INSTALL_LOGIN_ITEM" == "1" ]]; then
|
if [[ "$INSTALL_LOGIN_ITEM" == "1" ]]; then
|
||||||
"$SCRIPT_DIR/install-start-at-login.sh"
|
bash "$SCRIPT_DIR/install-start-at-login.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$OPEN_APP" == "1" ]]; then
|
if [[ "$OPEN_APP" == "1" ]]; then
|
||||||
|
|||||||
102
core/services/local-rag-index.md
Normal file
102
core/services/local-rag-index.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
type: service-design
|
||||||
|
status: active
|
||||||
|
updated: 2026-05-21
|
||||||
|
tags:
|
||||||
|
- ai-workspace
|
||||||
|
- rag
|
||||||
|
- index
|
||||||
|
---
|
||||||
|
|
||||||
|
# Local RAG Index
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add retrieval over canonical workspace memory without replacing the human-readable `project-knowledge/` vault.
|
||||||
|
|
||||||
|
The local index is derived and disposable. If the index disagrees with Markdown, the Markdown wins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
The first implementation is dependency-free and lexical:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scripts/aiw/indexer.py
|
||||||
|
```
|
||||||
|
|
||||||
|
It reads:
|
||||||
|
|
||||||
|
```text
|
||||||
|
project-knowledge/**/*.md
|
||||||
|
```
|
||||||
|
|
||||||
|
and writes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.aiw/indexes/<profile>/project-knowledge.jsonl
|
||||||
|
.aiw/indexes/<profile>/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
It skips:
|
||||||
|
|
||||||
|
```text
|
||||||
|
project-knowledge/09-templates/
|
||||||
|
```
|
||||||
|
|
||||||
|
so Obsidian templates do not appear as real memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Build the index:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/indexer.py build --profile fidelity
|
||||||
|
```
|
||||||
|
|
||||||
|
Check index status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/indexer.py status --profile fidelity
|
||||||
|
```
|
||||||
|
|
||||||
|
Search the index:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/indexer.py search "dismissal lifecycle" --profile fidelity
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Exposure
|
||||||
|
|
||||||
|
`aiw-context-mcp` exposes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
memory_hybrid_search
|
||||||
|
```
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- searches the derived local index when it exists
|
||||||
|
- returns cited paths, headings, snippets, scores, hashes, and mtimes
|
||||||
|
- falls back to live Markdown search when no index exists
|
||||||
|
- remains read-only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Upgrade Path
|
||||||
|
|
||||||
|
This layer can later add:
|
||||||
|
|
||||||
|
- full-text ranking
|
||||||
|
- embeddings
|
||||||
|
- Qdrant or Chroma as a local vector store
|
||||||
|
- hybrid lexical + semantic search
|
||||||
|
- reranking
|
||||||
|
- Mattermost evidence indexing with strict source filters
|
||||||
|
|
||||||
|
Do not make the vector store canonical. It should remain rebuildable from Markdown and selected evidence.
|
||||||
@@ -32,10 +32,30 @@ Use a staged model:
|
|||||||
- Avoid privileged helpers until a real system-level requirement appears.
|
- Avoid privileged helpers until a real system-level requirement appears.
|
||||||
|
|
||||||
3. **Future polished distribution**
|
3. **Future polished distribution**
|
||||||
- Create a signed/notarized `.app` or `.pkg`.
|
- Create a signed/notarized `.app` distributed in a `.dmg` with an Applications shortcut, or a `.pkg` only if privileged installation becomes necessary.
|
||||||
- Consider `SMAppService` for login item management from inside the app.
|
- Use `SMAppService` for login item management from inside the app so the user can toggle Start at Login in the UI instead of running `launchctl` scripts manually.
|
||||||
- Add a small daemon API if the UI needs richer lifecycle control than shelling out to `services.py`.
|
- Add a small daemon API if the UI needs richer lifecycle control than shelling out to `services.py`.
|
||||||
|
|
||||||
|
## Desired user-grade install flow
|
||||||
|
|
||||||
|
For a Cloudflare/Docker-like local experience, the target should be:
|
||||||
|
|
||||||
|
1. User opens `AIWorkspace.dmg`.
|
||||||
|
2. User drags `AIWorkspace.app` to `/Applications` or `~/Applications`.
|
||||||
|
3. User launches the app.
|
||||||
|
4. App shows service status and a **Start at Login** toggle.
|
||||||
|
5. App registers/unregisters itself as a login item using `SMAppService`.
|
||||||
|
6. App starts/stops workspace services through the service manager.
|
||||||
|
|
||||||
|
The existing shell scripts remain useful for development and bootstrap, but should not be the primary end-user experience once the app handles login item registration itself.
|
||||||
|
|
||||||
|
## Current implementation state
|
||||||
|
|
||||||
|
- `AIWorkspace.app` can be packaged from `apps/mac/AIWorkspace/scripts/package-app.sh`.
|
||||||
|
- `AIWorkspace.dmg` can be built from `apps/mac/AIWorkspace/scripts/build-dmg.sh`.
|
||||||
|
- The app UI includes a **Start at Login** toggle backed by `SMAppService.mainApp`.
|
||||||
|
- LaunchAgent scripts remain as development fallbacks, not the preferred user path.
|
||||||
|
|
||||||
## Why not LaunchDaemon now
|
## Why not LaunchDaemon now
|
||||||
|
|
||||||
The current services are user-context services:
|
The current services are user-context services:
|
||||||
|
|||||||
@@ -32,8 +32,15 @@ tags:
|
|||||||
- SampleApp should support explicit validation of both UIKit-host and SwiftUI-host scenarios. XFlowSDK should remain self-aware of which dismissal mechanism to use based on the active integration path, so SampleApp can exercise both paths without relying on stale singleton/default host-mode state.
|
- SampleApp should support explicit validation of both UIKit-host and SwiftUI-host scenarios. XFlowSDK should remain self-aware of which dismissal mechanism to use based on the active integration path, so SampleApp can exercise both paths without relying on stale singleton/default host-mode state.
|
||||||
- May 14 SampleApp iteration: SampleApp was updated to choose between UIKit-host (`initialViewController(...)`) and SwiftUI-host (`makeInitialFlowView(...)`) paths from the existing `Use SwiftUI` setting / feature-toggle path. XFlowSDK entrypoints now prepare the matching dismissal mechanism, and the SampleApp SwiftUI-host rendering was refined to use a `ViewBuilder`/`flowContent` approach rather than storing an `AnyView` in state. Next validation is SampleApp back-to-back host-mode switching plus Fid4 AccountLink revalidation in both host modes.
|
- May 14 SampleApp iteration: SampleApp was updated to choose between UIKit-host (`initialViewController(...)`) and SwiftUI-host (`makeInitialFlowView(...)`) paths from the existing `Use SwiftUI` setting / feature-toggle path. XFlowSDK entrypoints now prepare the matching dismissal mechanism, and the SampleApp SwiftUI-host rendering was refined to use a `ViewBuilder`/`flowContent` approach rather than storing an `AnyView` in state. Next validation is SampleApp back-to-back host-mode switching plus Fid4 AccountLink revalidation in both host modes.
|
||||||
- Jeff recommended expanding validation beyond AccountLink before PR review: test as many current Fid4 XFlow entry and dismissal points as possible in both default SwiftUI-host and forced UIKit-host modes, with particular attention to AO flows. Use the existing Confluence entry-point list as a starting point, but verify it against current code and temporary logs because some entries may be stale.
|
- Jeff recommended expanding validation beyond AccountLink before PR review: test as many current Fid4 XFlow entry and dismissal points as possible in both default SwiftUI-host and forced UIKit-host modes, with particular attention to AO flows. Use the existing Confluence entry-point list as a starting point, but verify it against current code and temporary logs because some entries may be stale.
|
||||||
- May 20 validation: sessions 004 and 005 show SwiftUI-host load, dismissal, and delegate cleanup coverage for HybridBrokerageAccountOpening and accountlink, but HybridYouthAccountOpening has load/start coverage only and needs complete dismissal coverage.
|
- May 20-21 validation: sessions 004, 005, 008, and 009 confirmed full validation for several key entry points:
|
||||||
- Remaining validation gaps on the combined PDIAP-12284 / PDIAP-15836 branch include AO deep-link coverage, Fid4 surface attribution for launch wrappers, common-launch flows, HybridBloomAccountOpening, and explicit UIKit-host mode validation. Plans are to open a draft PR today while validation continues.
|
- `HybridBloomAccountOpening` (Affiliation-Gated AO Modal): Fully validated in both UIKit host (Session 008) and SwiftUI host (Session 009) with full dismissal/cleanup confirmed. (Requires enabling affiliation flags and manually injecting `affiliation_id` / `branch_affiliation_id` UserDefaults keys in LLDB, then navigating Pre-login -> "Open an account" -> UK Invests -> consent flow).
|
||||||
|
- `HybridBrokerageAccountOpening`: Fully validated on both UIKit and SwiftUI hosts with full lifecycle/dismissal confirmed.
|
||||||
|
- `HybridYouthAccountOpening`: Fully validated on both UIKit and SwiftUI hosts with full lifecycle/dismissal confirmed, resolving the previous dismissal coverage gap.
|
||||||
|
- Remaining validation gaps on the combined PDIAP-12284 / PDIAP-15836 branch include:
|
||||||
|
- `accountlink` (P2P Transfer Flow): UIKit host validation is pending (SwiftUI host and dismissal are validated).
|
||||||
|
- `AODeeplinkLaunchView` (AO Deep-Link with Native/Web Toggle): untested on both branches (Native ON/OFF) and dismissal.
|
||||||
|
- Fid4 surface attribution for launch wrappers and common-launch flows.
|
||||||
|
- Draft PRs were opened for XFlowSDK and XFlowViewMaker on May 20 while validation continues.
|
||||||
- Keep the separate `HybridBrokerageAccountOpening` / `JointIdentityCheck` scenario out of `PDIAP-15765` scope unless later evidence proves it belongs there
|
- Keep the separate `HybridBrokerageAccountOpening` / `JointIdentityCheck` scenario out of `PDIAP-15765` scope unless later evidence proves it belongs there
|
||||||
- Include feature-flag planning for the broader UIKit-removal spike, including dismissal sequencing changes that affect consumers
|
- Include feature-flag planning for the broader UIKit-removal spike, including dismissal sequencing changes that affect consumers
|
||||||
- Thoroughly verify current `ApexBridgingAddressComponent` / rule-loading usage before describing it as inactive or dead code
|
- Thoroughly verify current `ApexBridgingAddressComponent` / rule-loading usage before describing it as inactive or dead code
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Update the per-ticket files first when scope, status, sequencing, or communicati
|
|||||||
|
|
||||||
- `PDIAP-12284` - Remove UIKit wrapping from XFlow
|
- `PDIAP-12284` - Remove UIKit wrapping from XFlow
|
||||||
Detail: `project-knowledge/02-work-items/pdiap-12284.md`
|
Detail: `project-knowledge/02-work-items/pdiap-12284.md`
|
||||||
Current note: moved to In Progress on May 12 and should be handled with `PDIAP-15836` in the same branch. Quy confirmed both stories can remain In Progress together, so no immediate Jira restructuring is required. Current implementation direction is to avoid Fid4 per-flow host-mode mapping and instead evaluate XFlowViewMaker-owned global host-mode resolution, with SwiftUI as default and `UIHostingController` only as an explicit temporary fallback. May 12 implementation pass resolved earlier shape concerns with neutral enum names, `iOS-XflowUIKitHostEnabled`, SwiftUI default semantics, and `AnyView` removed from builder internals. Fid4 now compiles after manual dependency/build handling. May 13 AccountLink runtime validation looks good for both SwiftUI-default and forced UIKit-host paths. May 14 SampleApp work added explicit UIKit-host vs SwiftUI-host validation paths. May 20 status: sessions 004 and 005 validated SwiftUI-host load, dismissal, and delegate cleanup for HybridBrokerageAccountOpening and accountlink, but HybridYouthAccountOpening needs complete dismissal coverage. Remaining gaps include AO deep-link coverage, launch wrappers, common-launch, HybridBloomAccountOpening, and explicit UIKit-host mode. Plan to open a draft PR today while validation continues.
|
Current note: moved to In Progress on May 12 and should be handled with `PDIAP-15836` in the same branch. Quy confirmed both stories can remain In Progress together, so no immediate Jira restructuring is required. Current implementation direction is to avoid Fid4 per-flow host-mode mapping and instead evaluate XFlowViewMaker-owned global host-mode resolution, with SwiftUI as default and `UIHostingController` only as an explicit temporary fallback. May 12 implementation pass resolved earlier shape concerns with neutral enum names, `iOS-XflowUIKitHostEnabled`, SwiftUI default semantics, and `AnyView` removed from builder internals. Fid4 now compiles after manual dependency/build handling. May 13 AccountLink runtime validation looks good for both SwiftUI-default and forced UIKit-host paths. May 14 SampleApp work added explicit UIKit-host vs SwiftUI-host validation paths. May 20-21 status: sessions 004, 005, 008, and 009 confirmed full validation for HybridBloomAccountOpening (both hosts, full lifecycle/dismissal confirmed), HybridBrokerageAccountOpening (both hosts, full lifecycle confirmed), and HybridYouthAccountOpening (both hosts, full lifecycle/dismissal confirmed, resolving the dismissal gap). Remaining gaps are accountlink UIKit host, AODeeplinkLaunchView (both toggle branches and dismissal), and launch wrappers/common-launch flows. Draft PRs were opened for XFlowSDK and XFlowViewMaker on May 20 while validation continues.
|
||||||
|
|
||||||
## Backlog / Future Reference
|
## Backlog / Future Reference
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ tags:
|
|||||||
- May 14 SampleApp iteration updated the sample to exercise both host scenarios explicitly: UIKit-host through `initialViewController(...)` and SwiftUI-host through `makeInitialFlowView(...)`, selected by the existing `Use SwiftUI` / feature-toggle path. XFlowSDK entrypoints should prepare their own matching dismissal mechanism so `endActivity` cannot route to the SwiftUI subject unless the SwiftUI host is actually active.
|
- May 14 SampleApp iteration updated the sample to exercise both host scenarios explicitly: UIKit-host through `initialViewController(...)` and SwiftUI-host through `makeInitialFlowView(...)`, selected by the existing `Use SwiftUI` / feature-toggle path. XFlowSDK entrypoints should prepare their own matching dismissal mechanism so `endActivity` cannot route to the SwiftUI subject unless the SwiftUI host is actually active.
|
||||||
- The SampleApp SwiftUI-host rendering was refined to avoid storing `AnyView` in state; SwiftUI-host content now lives behind a `ViewBuilder` / `flowContent` rendering path. This keeps local SampleApp code aligned with the branch goal of avoiding unnecessary `AnyView`, while preserving type erasure only at true public boundaries such as the existing XFlowViewMaker boundary if still required.
|
- The SampleApp SwiftUI-host rendering was refined to avoid storing `AnyView` in state; SwiftUI-host content now lives behind a `ViewBuilder` / `flowContent` rendering path. This keeps local SampleApp code aligned with the branch goal of avoiding unnecessary `AnyView`, while preserving type erasure only at true public boundaries such as the existing XFlowViewMaker boundary if still required.
|
||||||
- Jeff recommended expanding validation before review: test as many current Fid4 XFlow entry and dismissal points as possible in both default SwiftUI-host and forced UIKit-host modes, with particular attention to AO flows. Use the prior Confluence entry-point list as a starting point, but verify it against current code and temporary runtime logs before treating it as complete.
|
- Jeff recommended expanding validation before review: test as many current Fid4 XFlow entry and dismissal points as possible in both default SwiftUI-host and forced UIKit-host modes, with particular attention to AO flows. Use the prior Confluence entry-point list as a starting point, but verify it against current code and temporary runtime logs before treating it as complete.
|
||||||
- May 20 status: validation sessions 004 and 005 confirmed SwiftUI-host load, dismissal, and delegate cleanup for HybridBrokerageAccountOpening and accountlink, but HybridYouthAccountOpening needs complete dismissal coverage. Gaps remain in AO deep-link coverage, launch wrappers, common-launch, HybridBloomAccountOpening, and explicit UIKit-host validation. Plan to open a draft PR today while continuing validation.
|
- May 20-21 status: validation sessions 004, 005, 008, and 009 confirmed full validation for several key entry points:
|
||||||
|
- `HybridBloomAccountOpening` (Affiliation-Gated AO Modal): Fully validated in both UIKit host (Session 008) and SwiftUI host (Session 009) with full dismissal/cleanup confirmed.
|
||||||
|
- `HybridBrokerageAccountOpening`: Fully validated on both UIKit and SwiftUI hosts with full lifecycle/dismissal confirmed.
|
||||||
|
- `HybridYouthAccountOpening`: Fully validated on both UIKit and SwiftUI hosts with full lifecycle/dismissal confirmed, resolving the previous dismissal coverage gap.
|
||||||
|
- Remaining gaps: `accountlink` UIKit host, `AODeeplinkLaunchView` (both toggle branches and dismissal), and launch wrappers/common-launch flows. Draft PRs were opened for XFlowSDK and XFlowViewMaker on May 20 while validation continues.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ tags:
|
|||||||
- May 13 AccountLink runtime validation looks good in both SwiftUI-default and forced UIKit-host modes. Both paths preserved the intended dismissal sequencing, with delegate callbacks and session cleanup after confirmed dismissal.
|
- May 13 AccountLink runtime validation looks good in both SwiftUI-default and forced UIKit-host modes. Both paths preserved the intended dismissal sequencing, with delegate callbacks and session cleanup after confirmed dismissal.
|
||||||
- May 14 SampleApp work refined dismissal validation coverage: SampleApp can exercise both UIKit-host and SwiftUI-host paths, while XFlowSDK entrypoints prepare the matching dismissal mechanism for the active integration path. This supports validating that UIKit dismiss completion and SwiftUI `XFlowDismissalHost` lifecycle sequencing both converge on the canonical delegate/session teardown path.
|
- May 14 SampleApp work refined dismissal validation coverage: SampleApp can exercise both UIKit-host and SwiftUI-host paths, while XFlowSDK entrypoints prepare the matching dismissal mechanism for the active integration path. This supports validating that UIKit dismiss completion and SwiftUI `XFlowDismissalHost` lifecycle sequencing both converge on the canonical delegate/session teardown path.
|
||||||
- Jeff recommended broad Fid4 validation before PR review: test as many current XFlow entry and dismissal points as possible in both host modes, especially AO flows, and use temporary logs to confirm the active entry path, host mode, dismissal mechanism, delegate callbacks, and session cleanup.
|
- Jeff recommended broad Fid4 validation before PR review: test as many current XFlow entry and dismissal points as possible in both host modes, especially AO flows, and use temporary logs to confirm the active entry path, host mode, dismissal mechanism, delegate callbacks, and session cleanup.
|
||||||
- May 20 status: validation sessions 004 and 005 confirmed SwiftUI-host load, dismissal, and delegate cleanup for HybridBrokerageAccountOpening and accountlink, but HybridYouthAccountOpening needs complete dismissal coverage. Gaps remain in AO deep-link coverage, launch wrappers, common-launch, HybridBloomAccountOpening, and explicit UIKit-host validation. Plan to open a draft PR today while continuing validation.
|
- May 20-21 status: validation sessions 004, 005, 008, and 009 confirmed full validation for several key entry points:
|
||||||
|
- `HybridBloomAccountOpening` (Affiliation-Gated AO Modal): Fully validated in both UIKit host (Session 008) and SwiftUI host (Session 009) with full dismissal/cleanup confirmed.
|
||||||
|
- `HybridBrokerageAccountOpening`: Fully validated on both UIKit and SwiftUI hosts with full lifecycle/dismissal confirmed.
|
||||||
|
- `HybridYouthAccountOpening`: Fully validated on both UIKit and SwiftUI hosts with full lifecycle/dismissal confirmed, resolving the previous dismissal coverage gap.
|
||||||
|
- Remaining gaps: `accountlink` UIKit host, `AODeeplinkLaunchView` (both toggle branches and dismissal), and launch wrappers/common-launch flows. Draft PRs were opened for XFlowSDK and XFlowViewMaker on May 20 while validation continues.
|
||||||
- Quy confirmed on May 13 that this story and `PDIAP-12284` can both remain In Progress together, so no immediate Jira restructuring is required.
|
- Quy confirmed on May 13 that this story and `PDIAP-12284` can both remain In Progress together, so no immediate Jira restructuring is required.
|
||||||
- Sequenced after `PDIAP-15838` source work, but merge/release is delayed until after REST-transition consumer validation
|
- Sequenced after `PDIAP-15838` source work, but merge/release is delayed until after REST-transition consumer validation
|
||||||
- Sized at `8` points
|
- Sized at `8` points
|
||||||
|
|||||||
@@ -23,4 +23,7 @@ updated: 2026-05-20
|
|||||||
|
|
||||||
## Work Done
|
## Work Done
|
||||||
|
|
||||||
- Previous-day project evidence confirms the duplicate account-opening investigation is still waiting on Adam's Discourse details; no new `xflog` Discourse post had been seen yet.
|
- Opened draft PRs for XFlowSDK and XFlowViewMaker for the combined PDIAP-12284 / PDIAP-15836 branch.
|
||||||
|
- Shared the draft PRs with the team on Teams to begin the review process.
|
||||||
|
- Monitored Discourse for Adam's details on the duplicate account-opening issue, which is still pending.
|
||||||
|
|
||||||
|
|||||||
31
project-knowledge/06-daily/2026-05-21.md
Normal file
31
project-knowledge/06-daily/2026-05-21.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
type: daily
|
||||||
|
project: fidelity
|
||||||
|
date: 2026-05-21
|
||||||
|
updated: 2026-05-21
|
||||||
|
focus:
|
||||||
|
- PDIAP-12284
|
||||||
|
- PDIAP-15836
|
||||||
|
work-items:
|
||||||
|
- PDIAP-12284
|
||||||
|
- PDIAP-15836
|
||||||
|
blockers: []
|
||||||
|
tags:
|
||||||
|
- daily
|
||||||
|
- fidelity
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-05-21
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- Screenshot evidence from the XFlow entry-point validation checklist indicates `HybridBloomAccountOpening` has both UIKit-host and SwiftUI-host validation marked complete, with full dismissal/cleanup confirmed for both host modes.
|
||||||
|
- `HybridBrokerageAccountOpening` and `HybridYouthAccountOpening` are also marked as validated for SwiftUI host, UIKit host, and full lifecycle/dismissal coverage in the checklist.
|
||||||
|
- The `accountLink` P2P transfer flow is marked validated for SwiftUI host and dismissal on SwiftUI host, but UIKit-host validation is still pending; the checklist notes Fid4 surface attribution is unclear and may go through `XFlowCommonLaunchView` or direct FTTransfer builder.
|
||||||
|
- `ADDeepLinkLaunchView` remains untested in the checklist for native-on, native-off, and dismissal paths.
|
||||||
|
|
||||||
|
## Validation To Run
|
||||||
|
|
||||||
|
- Complete UIKit-host validation for `accountLink` / P2P transfer flow.
|
||||||
|
- Validate `ADDeepLinkLaunchView` for both native toggle branches and dismissal behavior.
|
||||||
|
- Keep using the checklist as a general entry-point guide rather than a session-by-session log.
|
||||||
@@ -32,6 +32,18 @@ python3 scripts/aiw/services.py start --profile fidelity --group inbox
|
|||||||
|
|
||||||
The service manager unifies startup and status. It does not move capture behavior into the MCP.
|
The service manager unifies startup and status. It does not move capture behavior into the MCP.
|
||||||
|
|
||||||
|
## Local project-knowledge index
|
||||||
|
|
||||||
|
The workspace includes a dependency-free local indexer for canonical Markdown memory. The index is derived from `project-knowledge/` and written under `.aiw/indexes/<profile>/`; it is safe to delete and rebuild.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/indexer.py build --profile fidelity
|
||||||
|
python3 scripts/aiw/indexer.py status --profile fidelity
|
||||||
|
python3 scripts/aiw/indexer.py search "dismissal lifecycle" --profile fidelity
|
||||||
|
```
|
||||||
|
|
||||||
|
`aiw-context-mcp` exposes the same derived search through the read-only `memory_hybrid_search` tool and falls back to live Markdown search if the index has not been built yet.
|
||||||
|
|
||||||
## Robustness features
|
## Robustness features
|
||||||
|
|
||||||
- Manifest validation before lifecycle actions.
|
- Manifest validation before lifecycle actions.
|
||||||
@@ -47,4 +59,5 @@ The service manager unifies startup and status. It does not move capture behavio
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/aiw/test_services.py
|
python3 scripts/aiw/test_services.py
|
||||||
|
python3 scripts/aiw/test_indexer.py
|
||||||
```
|
```
|
||||||
|
|||||||
258
scripts/aiw/indexer.py
Normal file
258
scripts/aiw/indexer.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dependency-free local indexer for AI Workspace canonical Markdown memory.
|
||||||
|
|
||||||
|
This is intentionally a small lexical/hybrid-ready index. It keeps
|
||||||
|
`project-knowledge/` as the source of truth and writes a derived, disposable
|
||||||
|
JSONL index under `.aiw/indexes/<profile>/`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
INDEX_ROOT = ROOT / ".aiw" / "indexes"
|
||||||
|
DEFAULT_PROFILE = "fidelity"
|
||||||
|
MAX_CHARS = 1800
|
||||||
|
OVERLAP_CHARS = 180
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Chunk:
|
||||||
|
chunk_id: str
|
||||||
|
path: str
|
||||||
|
heading: str
|
||||||
|
text: str
|
||||||
|
mtime: float
|
||||||
|
sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
def project_knowledge_dir(profile: str) -> Path:
|
||||||
|
profile_base = ROOT / "profiles" / profile
|
||||||
|
candidate = profile_base / "project-knowledge"
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return ROOT / "project-knowledge"
|
||||||
|
|
||||||
|
|
||||||
|
def index_dir(profile: str) -> Path:
|
||||||
|
return INDEX_ROOT / profile
|
||||||
|
|
||||||
|
|
||||||
|
def index_path(profile: str) -> Path:
|
||||||
|
return index_dir(profile) / "project-knowledge.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_path(profile: str) -> Path:
|
||||||
|
return index_dir(profile) / "manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_space(text: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def tokens(text: str) -> set[str]:
|
||||||
|
return {item for item in re.findall(r"[a-z0-9][a-z0-9_-]{1,}", text.lower()) if len(item) > 1}
|
||||||
|
|
||||||
|
|
||||||
|
def iter_markdown_files(base: Path) -> list[Path]:
|
||||||
|
files: list[Path] = []
|
||||||
|
for path in sorted(base.rglob("*.md")):
|
||||||
|
rel = path.relative_to(base)
|
||||||
|
if str(rel).startswith("09-templates/"):
|
||||||
|
continue
|
||||||
|
files.append(path)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def heading_for_line(line: str, current: str) -> str:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
return stripped.lstrip("#").strip() or current
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def split_sections(text: str) -> list[tuple[str, str]]:
|
||||||
|
sections: list[tuple[str, list[str]]] = [("", [])]
|
||||||
|
current_heading = ""
|
||||||
|
for line in text.splitlines():
|
||||||
|
new_heading = heading_for_line(line, current_heading)
|
||||||
|
if new_heading != current_heading and line.strip().startswith("#"):
|
||||||
|
current_heading = new_heading
|
||||||
|
sections.append((current_heading, [line]))
|
||||||
|
else:
|
||||||
|
sections[-1][1].append(line)
|
||||||
|
return [(heading, "\n".join(lines).strip()) for heading, lines in sections if "\n".join(lines).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(section_text: str, max_chars: int = MAX_CHARS, overlap_chars: int = OVERLAP_CHARS) -> list[str]:
|
||||||
|
text = section_text.strip()
|
||||||
|
if len(text) <= max_chars:
|
||||||
|
return [text] if text else []
|
||||||
|
chunks: list[str] = []
|
||||||
|
start = 0
|
||||||
|
while start < len(text):
|
||||||
|
end = min(len(text), start + max_chars)
|
||||||
|
if end < len(text):
|
||||||
|
boundary = max(text.rfind("\n\n", start, end), text.rfind(". ", start, end))
|
||||||
|
if boundary > start + max_chars // 2:
|
||||||
|
end = boundary + 1
|
||||||
|
chunk = text[start:end].strip()
|
||||||
|
if chunk:
|
||||||
|
chunks.append(chunk)
|
||||||
|
if end >= len(text):
|
||||||
|
break
|
||||||
|
start = max(0, end - overlap_chars)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def build_chunks(profile: str) -> list[Chunk]:
|
||||||
|
base = project_knowledge_dir(profile)
|
||||||
|
chunks: list[Chunk] = []
|
||||||
|
for path in iter_markdown_files(base):
|
||||||
|
raw = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
rel = str(path.relative_to(ROOT))
|
||||||
|
digest = hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()
|
||||||
|
mtime = path.stat().st_mtime
|
||||||
|
for section_index, (heading, section) in enumerate(split_sections(raw)):
|
||||||
|
for chunk_index, chunk in enumerate(chunk_text(section)):
|
||||||
|
chunk_digest = hashlib.sha256(f"{rel}\n{section_index}\n{chunk_index}\n{chunk}".encode("utf-8")).hexdigest()[:16]
|
||||||
|
chunks.append(Chunk(chunk_id=chunk_digest, path=rel, heading=heading, text=chunk, mtime=mtime, sha256=digest))
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def write_index(profile: str) -> dict[str, Any]:
|
||||||
|
out_dir = index_dir(profile)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
chunks = build_chunks(profile)
|
||||||
|
with index_path(profile).open("w", encoding="utf-8") as handle:
|
||||||
|
for chunk in chunks:
|
||||||
|
handle.write(json.dumps(chunk.__dict__, ensure_ascii=False, sort_keys=True) + "\n")
|
||||||
|
files = sorted({chunk.path for chunk in chunks})
|
||||||
|
manifest = {
|
||||||
|
"profile": profile,
|
||||||
|
"source": str(project_knowledge_dir(profile).relative_to(ROOT)),
|
||||||
|
"canonical": False,
|
||||||
|
"derived_from": "project-knowledge",
|
||||||
|
"index_type": "lexical-markdown-chunks",
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"file_count": len(files),
|
||||||
|
"chunk_count": len(chunks),
|
||||||
|
"index_path": str(index_path(profile).relative_to(ROOT)),
|
||||||
|
}
|
||||||
|
manifest_path(profile).write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def read_index(profile: str) -> list[dict[str, Any]]:
|
||||||
|
path = index_path(profile)
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rows.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def score_chunk(query: str, query_tokens: set[str], chunk: dict[str, Any]) -> float:
|
||||||
|
text = str(chunk.get("text") or "")
|
||||||
|
haystack = f"{chunk.get('path', '')} {chunk.get('heading', '')} {text}".lower()
|
||||||
|
exact = haystack.count(query.lower())
|
||||||
|
chunk_tokens = tokens(haystack)
|
||||||
|
overlap = len(query_tokens & chunk_tokens)
|
||||||
|
if exact == 0 and overlap == 0:
|
||||||
|
return 0.0
|
||||||
|
heading_bonus = 1.5 if query.lower() in str(chunk.get("heading") or "").lower() else 0.0
|
||||||
|
path_bonus = 1.0 if query.lower() in str(chunk.get("path") or "").lower() else 0.0
|
||||||
|
return exact * 5.0 + overlap * 1.25 + heading_bonus + path_bonus
|
||||||
|
|
||||||
|
|
||||||
|
def snippet_for(query: str, text: str, width: int = 520) -> str:
|
||||||
|
lowered = text.lower()
|
||||||
|
index = lowered.find(query.lower()) if query else -1
|
||||||
|
if index < 0:
|
||||||
|
query_terms = tokens(query)
|
||||||
|
candidates = [lowered.find(term) for term in query_terms if lowered.find(term) >= 0]
|
||||||
|
index = min(candidates) if candidates else 0
|
||||||
|
start = max(0, index - width // 2)
|
||||||
|
end = min(len(text), start + width)
|
||||||
|
return normalize_space(text[start:end])
|
||||||
|
|
||||||
|
|
||||||
|
def search_index(profile: str, query: str, limit: int = 10) -> dict[str, Any]:
|
||||||
|
query = query.strip()
|
||||||
|
if not query:
|
||||||
|
raise SystemExit("query is required")
|
||||||
|
rows = read_index(profile)
|
||||||
|
query_tokens = tokens(query)
|
||||||
|
scored: list[tuple[float, dict[str, Any]]] = []
|
||||||
|
for row in rows:
|
||||||
|
score = score_chunk(query, query_tokens, row)
|
||||||
|
if score > 0:
|
||||||
|
scored.append((score, row))
|
||||||
|
scored.sort(key=lambda item: (-item[0], item[1].get("path", ""), item[1].get("chunk_id", "")))
|
||||||
|
matches = []
|
||||||
|
for score, row in scored[:limit]:
|
||||||
|
matches.append({
|
||||||
|
"score": round(score, 3),
|
||||||
|
"path": row.get("path"),
|
||||||
|
"heading": row.get("heading"),
|
||||||
|
"chunk_id": row.get("chunk_id"),
|
||||||
|
"snippet": snippet_for(query, str(row.get("text") or "")),
|
||||||
|
"mtime": row.get("mtime"),
|
||||||
|
"sha256": row.get("sha256"),
|
||||||
|
})
|
||||||
|
manifest = {}
|
||||||
|
if manifest_path(profile).is_file():
|
||||||
|
manifest = json.loads(manifest_path(profile).read_text(encoding="utf-8"))
|
||||||
|
return {"profile": profile, "query": query, "canonical": False, "source": "derived-index", "manifest": manifest, "matches": matches}
|
||||||
|
|
||||||
|
|
||||||
|
def status(profile: str) -> dict[str, Any]:
|
||||||
|
manifest_file = manifest_path(profile)
|
||||||
|
if not manifest_file.is_file():
|
||||||
|
return {"profile": profile, "indexed": False, "index_path": str(index_path(profile).relative_to(ROOT))}
|
||||||
|
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||||
|
path = index_path(profile)
|
||||||
|
manifest["indexed"] = path.is_file()
|
||||||
|
manifest["index_bytes"] = path.stat().st_size if path.is_file() else 0
|
||||||
|
manifest["age_seconds"] = int(time.time() - datetime.fromisoformat(manifest["created_at"]).timestamp()) if manifest.get("created_at") else None
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
for name in ["build", "status"]:
|
||||||
|
command = subparsers.add_parser(name)
|
||||||
|
command.add_argument("--profile", default=DEFAULT_PROFILE)
|
||||||
|
search = subparsers.add_parser("search")
|
||||||
|
search.add_argument("query")
|
||||||
|
search.add_argument("--profile", default=DEFAULT_PROFILE)
|
||||||
|
search.add_argument("--limit", type=int, default=10)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.command == "build":
|
||||||
|
payload = write_index(args.profile)
|
||||||
|
elif args.command == "search":
|
||||||
|
payload = search_index(args.profile, args.query, limit=max(1, min(args.limit, 50)))
|
||||||
|
else:
|
||||||
|
payload = status(args.profile)
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
56
scripts/aiw/test_indexer.py
Normal file
56
scripts/aiw/test_indexer.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
INDEXER_PATH = Path(__file__).with_name("indexer.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("aiw_indexer", INDEXER_PATH)
|
||||||
|
indexer = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader is not None
|
||||||
|
sys.modules[SPEC.name] = indexer
|
||||||
|
SPEC.loader.exec_module(indexer)
|
||||||
|
|
||||||
|
|
||||||
|
class IndexerTests(unittest.TestCase):
|
||||||
|
def test_build_skips_templates_and_searches_canonical_files(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
real = root / "project-knowledge" / "03-context" / "project.md"
|
||||||
|
template = root / "project-knowledge" / "09-templates" / "daily.md"
|
||||||
|
real.parent.mkdir(parents=True)
|
||||||
|
template.parent.mkdir(parents=True)
|
||||||
|
real.write_text("# XFlow\nDismissal lifecycle context", encoding="utf-8")
|
||||||
|
template.write_text("# XFlow\nTemplate-only text", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch.object(indexer, "ROOT", root), patch.object(indexer, "INDEX_ROOT", root / ".aiw" / "indexes"):
|
||||||
|
manifest = indexer.write_index("fidelity")
|
||||||
|
result = indexer.search_index("fidelity", "dismissal lifecycle", limit=5)
|
||||||
|
|
||||||
|
self.assertEqual(manifest["file_count"], 1)
|
||||||
|
self.assertEqual(len(result["matches"]), 1)
|
||||||
|
self.assertIn("03-context/project.md", result["matches"][0]["path"])
|
||||||
|
|
||||||
|
def test_status_reports_unindexed_profile(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
with patch.object(indexer, "ROOT", root), patch.object(indexer, "INDEX_ROOT", root / ".aiw" / "indexes"):
|
||||||
|
result = indexer.status("fidelity")
|
||||||
|
|
||||||
|
self.assertFalse(result["indexed"])
|
||||||
|
self.assertIn(".aiw/indexes/fidelity/project-knowledge.jsonl", result["index_path"])
|
||||||
|
|
||||||
|
def test_cli_search_payload_is_json_serializable(self) -> None:
|
||||||
|
payload = {"matches": [{"path": "project-knowledge/01-current/current-work.md", "score": 1.0}]}
|
||||||
|
self.assertIsInstance(json.dumps(payload), str)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -42,10 +42,19 @@ python3 scripts/mcp/aiw-context-mcp/server.py --transport stdio
|
|||||||
- `communication_thread_context`
|
- `communication_thread_context`
|
||||||
- `project_current_context`
|
- `project_current_context`
|
||||||
- `project_search_memory`
|
- `project_search_memory`
|
||||||
|
- `memory_hybrid_search`
|
||||||
- `photos_latest`
|
- `photos_latest`
|
||||||
|
|
||||||
All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown under `project-knowledge/`.
|
All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown under `project-knowledge/`.
|
||||||
|
|
||||||
|
`memory_hybrid_search` reads the derived local index built by:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/indexer.py build --profile fidelity
|
||||||
|
```
|
||||||
|
|
||||||
|
If the index is missing, it falls back to bounded live Markdown search over `project-knowledge/`. The index is not canonical memory; `project-knowledge/` remains the source of truth.
|
||||||
|
|
||||||
Mattermost latest/date/standup tools filter to the active profile's context channels by default. For Fidelity, that list lives in `profiles/fidelity/context-sources.json`. Pass explicit `channels` to override the profile list, or `include_all_channels: true` when broad unfiltered mirror evidence is intentionally needed.
|
Mattermost latest/date/standup tools filter to the active profile's context channels by default. For Fidelity, that list lives in `profiles/fidelity/context-sources.json`. Pass explicit `channels` to override the profile list, or `include_all_channels: true` when broad unfiltered mirror evidence is intentionally needed.
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ owned by the AI Workspace Service Manager.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
@@ -25,6 +27,7 @@ PROTOCOL_VERSION = "2025-06-18"
|
|||||||
SERVER_NAME = "aiw-context-mcp"
|
SERVER_NAME = "aiw-context-mcp"
|
||||||
SERVER_VERSION = "0.1.0"
|
SERVER_VERSION = "0.1.0"
|
||||||
LOCAL_ENV = ROOT / "scripts" / "mattermost-proxy" / ".env"
|
LOCAL_ENV = ROOT / "scripts" / "mattermost-proxy" / ".env"
|
||||||
|
INDEX_ROOT = ROOT / ".aiw" / "indexes"
|
||||||
|
|
||||||
|
|
||||||
def load_local_env(path: Path = LOCAL_ENV) -> None:
|
def load_local_env(path: Path = LOCAL_ENV) -> None:
|
||||||
@@ -283,6 +286,100 @@ def project_search_memory(args: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return tool_result({"profile": profile, "canonical": True, "query": query, "matches": matches})
|
return tool_result({"profile": profile, "canonical": True, "query": query, "matches": matches})
|
||||||
|
|
||||||
|
|
||||||
|
def index_path(profile: str) -> Path:
|
||||||
|
return INDEX_ROOT / profile / "project-knowledge.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def index_manifest_path(profile: str) -> Path:
|
||||||
|
return INDEX_ROOT / profile / "manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def search_tokens(text: str) -> set[str]:
|
||||||
|
return {item for item in re.findall(r"[a-z0-9][a-z0-9_-]{1,}", text.lower()) if len(item) > 1}
|
||||||
|
|
||||||
|
|
||||||
|
def read_project_index(profile: str) -> list[dict[str, Any]]:
|
||||||
|
path = index_path(profile)
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rows.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def indexed_snippet(query: str, text: str, width: int = 520) -> str:
|
||||||
|
lowered = text.lower()
|
||||||
|
index = lowered.find(query.lower()) if query else -1
|
||||||
|
if index < 0:
|
||||||
|
positions = [lowered.find(term) for term in search_tokens(query) if lowered.find(term) >= 0]
|
||||||
|
index = min(positions) if positions else 0
|
||||||
|
start = max(0, index - width // 2)
|
||||||
|
end = min(len(text), start + width)
|
||||||
|
return re.sub(r"\s+", " ", text[start:end]).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def score_index_row(query: str, query_tokens: set[str], row: dict[str, Any]) -> float:
|
||||||
|
text = str(row.get("text") or "")
|
||||||
|
haystack = f"{row.get('path', '')} {row.get('heading', '')} {text}".lower()
|
||||||
|
exact = haystack.count(query.lower())
|
||||||
|
overlap = len(query_tokens & search_tokens(haystack))
|
||||||
|
if exact == 0 and overlap == 0:
|
||||||
|
return 0.0
|
||||||
|
heading_bonus = 1.5 if query.lower() in str(row.get("heading") or "").lower() else 0.0
|
||||||
|
path_bonus = 1.0 if query.lower() in str(row.get("path") or "").lower() else 0.0
|
||||||
|
return exact * 5.0 + overlap * 1.25 + heading_bonus + path_bonus
|
||||||
|
|
||||||
|
|
||||||
|
def read_index_manifest(profile: str) -> dict[str, Any]:
|
||||||
|
path = index_manifest_path(profile)
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def memory_hybrid_search(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = str(args.get("profile") or "fidelity")
|
||||||
|
query = str(args.get("query") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return tool_error("query is required")
|
||||||
|
limit = clamp_limit(args.get("limit"), default=10, maximum=50)
|
||||||
|
rows = read_project_index(profile)
|
||||||
|
if not rows:
|
||||||
|
fallback = project_search_memory({"profile": profile, "query": query, "limit": limit})["structuredContent"]
|
||||||
|
fallback["source"] = "live-project-knowledge-fallback"
|
||||||
|
fallback["index_available"] = False
|
||||||
|
return tool_result(fallback)
|
||||||
|
query_tokens = search_tokens(query)
|
||||||
|
scored = []
|
||||||
|
for row in rows:
|
||||||
|
score = score_index_row(query, query_tokens, row)
|
||||||
|
if score > 0:
|
||||||
|
scored.append((score, row))
|
||||||
|
scored.sort(key=lambda item: (-item[0], item[1].get("path", ""), item[1].get("chunk_id", "")))
|
||||||
|
matches = []
|
||||||
|
for score, row in scored[:limit]:
|
||||||
|
text = str(row.get("text") or "")
|
||||||
|
matches.append({
|
||||||
|
"score": round(score, 3),
|
||||||
|
"path": row.get("path"),
|
||||||
|
"heading": row.get("heading"),
|
||||||
|
"chunk_id": row.get("chunk_id") or hashlib.sha256(text.encode("utf-8")).hexdigest()[:16],
|
||||||
|
"snippet": indexed_snippet(query, text),
|
||||||
|
"mtime": row.get("mtime"),
|
||||||
|
"sha256": row.get("sha256"),
|
||||||
|
})
|
||||||
|
return tool_result({"profile": profile, "canonical": False, "source": "derived-project-knowledge-index", "index_available": True, "manifest": read_index_manifest(profile), "query": query, "matches": matches})
|
||||||
|
|
||||||
|
|
||||||
def photos_latest(args: dict[str, Any]) -> dict[str, Any]:
|
def photos_latest(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
profile = str(args.get("profile") or "fidelity")
|
profile = str(args.get("profile") or "fidelity")
|
||||||
limit = clamp_limit(args.get("limit"), default=20, maximum=100)
|
limit = clamp_limit(args.get("limit"), default=20, maximum=100)
|
||||||
@@ -373,6 +470,7 @@ TOOLS: dict[str, dict[str, Any]] = {
|
|||||||
"communication_thread_context": {"handler": communication_thread_context, "description": "Read Mattermost mirror evidence for a thread id.", "properties": {"profile": {"type": "string"}, "thread_id": {"type": "string"}, "limit": {"type": "integer"}}},
|
"communication_thread_context": {"handler": communication_thread_context, "description": "Read Mattermost mirror evidence for a thread id.", "properties": {"profile": {"type": "string"}, "thread_id": {"type": "string"}, "limit": {"type": "integer"}}},
|
||||||
"project_current_context": {"handler": project_current_context, "description": "Read canonical current-work and work-items context.", "properties": {"profile": {"type": "string"}}},
|
"project_current_context": {"handler": project_current_context, "description": "Read canonical current-work and work-items context.", "properties": {"profile": {"type": "string"}}},
|
||||||
"project_search_memory": {"handler": project_search_memory, "description": "Search canonical project-knowledge Markdown files.", "properties": {"profile": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer"}}},
|
"project_search_memory": {"handler": project_search_memory, "description": "Search canonical project-knowledge Markdown files.", "properties": {"profile": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer"}}},
|
||||||
|
"memory_hybrid_search": {"handler": memory_hybrid_search, "description": "Search the derived local project-knowledge index with lexical scoring and source citations; falls back to live Markdown search if no index exists.", "properties": {"profile": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer"}}},
|
||||||
"photos_latest": {"handler": photos_latest, "description": "List recent local Photo Inbox files without embedding image data.", "properties": {"profile": {"type": "string"}, "limit": {"type": "integer"}}},
|
"photos_latest": {"handler": photos_latest, "description": "List recent local Photo Inbox files without embedding image data.", "properties": {"profile": {"type": "string"}, "limit": {"type": "integer"}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class ContextMCPTests(unittest.TestCase):
|
|||||||
|
|
||||||
names = {tool["name"] for tool in response["result"]["tools"]}
|
names = {tool["name"] for tool in response["result"]["tools"]}
|
||||||
self.assertIn("project_search_memory", names)
|
self.assertIn("project_search_memory", names)
|
||||||
|
self.assertIn("memory_hybrid_search", names)
|
||||||
self.assertIn("communication_latest", names)
|
self.assertIn("communication_latest", names)
|
||||||
|
|
||||||
def test_initialize_response_declares_resources(self) -> None:
|
def test_initialize_response_declares_resources(self) -> None:
|
||||||
@@ -158,6 +159,43 @@ class ContextMCPTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(result["matches"]), 1)
|
self.assertEqual(len(result["matches"]), 1)
|
||||||
self.assertIn("03-context/project.md", result["matches"][0]["path"])
|
self.assertIn("03-context/project.md", result["matches"][0]["path"])
|
||||||
|
|
||||||
|
def test_memory_hybrid_search_uses_index_when_available(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
index = root / ".aiw" / "indexes" / "fidelity" / "project-knowledge.jsonl"
|
||||||
|
manifest = root / ".aiw" / "indexes" / "fidelity" / "manifest.json"
|
||||||
|
index.parent.mkdir(parents=True)
|
||||||
|
index.write_text(json.dumps({
|
||||||
|
"chunk_id": "abc",
|
||||||
|
"path": "project-knowledge/03-context/project.md",
|
||||||
|
"heading": "XFlow",
|
||||||
|
"text": "Dismissal lifecycle sequencing for XFlow",
|
||||||
|
"mtime": 1.0,
|
||||||
|
"sha256": "hash",
|
||||||
|
}) + "\n", encoding="utf-8")
|
||||||
|
manifest.write_text(json.dumps({"chunk_count": 1}), encoding="utf-8")
|
||||||
|
|
||||||
|
with patch.object(server, "ROOT", root), patch.object(server, "INDEX_ROOT", root / ".aiw" / "indexes"):
|
||||||
|
result = server.memory_hybrid_search({"profile": "fidelity", "query": "dismissal lifecycle"})["structuredContent"]
|
||||||
|
|
||||||
|
self.assertTrue(result["index_available"])
|
||||||
|
self.assertEqual(result["source"], "derived-project-knowledge-index")
|
||||||
|
self.assertEqual(result["matches"][0]["chunk_id"], "abc")
|
||||||
|
|
||||||
|
def test_memory_hybrid_search_falls_back_without_index(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
real = root / "project-knowledge" / "03-context" / "project.md"
|
||||||
|
real.parent.mkdir(parents=True)
|
||||||
|
real.write_text("Important XFlow context", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch.object(server, "ROOT", root), patch.object(server, "INDEX_ROOT", root / ".aiw" / "indexes"):
|
||||||
|
result = server.memory_hybrid_search({"profile": "fidelity", "query": "XFlow"})["structuredContent"]
|
||||||
|
|
||||||
|
self.assertFalse(result["index_available"])
|
||||||
|
self.assertEqual(result["source"], "live-project-knowledge-fallback")
|
||||||
|
self.assertEqual(len(result["matches"]), 1)
|
||||||
|
|
||||||
def test_previous_workday_skips_weekend(self) -> None:
|
def test_previous_workday_skips_weekend(self) -> None:
|
||||||
monday = date(2026, 5, 18)
|
monday = date(2026, 5, 18)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user