Compare commits

..

3 Commits

19 changed files with 746 additions and 11 deletions

1
.gitignore vendored
View File

@@ -44,3 +44,4 @@ project-knowledge/.obsidian/cache/
# AI Workspace local service runtime
.aiw/runtime/
.aiw/indexes/

View File

@@ -28,6 +28,20 @@ Install to `~/Applications/AIWorkspace.app`:
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:
```bash
@@ -36,7 +50,9 @@ apps/mac/AIWorkspace/scripts/install.sh --start-at-login --open
## 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
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.
- 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`.

View File

@@ -1,5 +1,6 @@
import AppKit
import Foundation
import ServiceManagement
import SwiftUI
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 lastError: String?
@Published private(set) var lanIP: String?
@Published private(set) var loginItemStatus: SMAppService.Status = .notRegistered
@Published private(set) var isRefreshing = false
let profile: String
@@ -53,12 +55,42 @@ final class ServiceStatusModel: ObservableObject {
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
report = try JSONDecoder().decode(StatusReport.self, from: data)
lanIP = await NetworkInfo.primaryLANIP()
loginItemStatus = SMAppService.mainApp.status
lastError = nil
} catch {
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() {
runAction(["start", "--profile", profile])
}
@@ -219,6 +251,21 @@ struct ServiceMenuView: View {
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()
HStack {
if let error = model.lastError {

View 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"

View File

@@ -22,10 +22,10 @@ for arg in "$@"; do
esac
done
"$SCRIPT_DIR/package-app.sh" --install
bash "$SCRIPT_DIR/package-app.sh" --install
if [[ "$INSTALL_LOGIN_ITEM" == "1" ]]; then
"$SCRIPT_DIR/install-start-at-login.sh"
bash "$SCRIPT_DIR/install-start-at-login.sh"
fi
if [[ "$OPEN_APP" == "1" ]]; then

View 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.

View File

@@ -32,10 +32,30 @@ Use a staged model:
- Avoid privileged helpers until a real system-level requirement appears.
3. **Future polished distribution**
- Create a signed/notarized `.app` or `.pkg`.
- Consider `SMAppService` for login item management from inside the app.
- Create a signed/notarized `.app` distributed in a `.dmg` with an Applications shortcut, or a `.pkg` only if privileged installation becomes necessary.
- 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`.
## 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
The current services are user-context services:

View File

@@ -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.
- 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.
- 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.
- 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.
- May 20-21 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. (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
- 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

View File

@@ -28,7 +28,7 @@ Update the per-ticket files first when scope, status, sequencing, or communicati
- `PDIAP-12284` - Remove UIKit wrapping from XFlow
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

View File

@@ -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.
- 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.
- 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.
---

View File

@@ -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 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.
- 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.
- Sequenced after `PDIAP-15838` source work, but merge/release is delayed until after REST-transition consumer validation
- Sized at `8` points

View File

@@ -23,4 +23,7 @@ updated: 2026-05-20
## 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.

View 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.

View File

@@ -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.
## 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
- Manifest validation before lifecycle actions.
@@ -47,4 +59,5 @@ The service manager unifies startup and status. It does not move capture behavio
```bash
python3 scripts/aiw/test_services.py
python3 scripts/aiw/test_indexer.py
```

258
scripts/aiw/indexer.py Normal file
View 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()

View 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()

View File

@@ -42,10 +42,19 @@ python3 scripts/mcp/aiw-context-mcp/server.py --transport stdio
- `communication_thread_context`
- `project_current_context`
- `project_search_memory`
- `memory_hybrid_search`
- `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/`.
`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.
## Resources

View File

@@ -9,8 +9,10 @@ owned by the AI Workspace Service Manager.
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import urllib.parse
from datetime import date, datetime, timedelta
@@ -25,6 +27,7 @@ PROTOCOL_VERSION = "2025-06-18"
SERVER_NAME = "aiw-context-mcp"
SERVER_VERSION = "0.1.0"
LOCAL_ENV = ROOT / "scripts" / "mattermost-proxy" / ".env"
INDEX_ROOT = ROOT / ".aiw" / "indexes"
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})
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]:
profile = str(args.get("profile") or "fidelity")
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"}}},
"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"}}},
"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"}}},
}

View File

@@ -32,6 +32,7 @@ class ContextMCPTests(unittest.TestCase):
names = {tool["name"] for tool in response["result"]["tools"]}
self.assertIn("project_search_memory", names)
self.assertIn("memory_hybrid_search", names)
self.assertIn("communication_latest", names)
def test_initialize_response_declares_resources(self) -> None:
@@ -158,6 +159,43 @@ class ContextMCPTests(unittest.TestCase):
self.assertEqual(len(result["matches"]), 1)
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:
monday = date(2026, 5, 18)