From f0246c3e8651eebd4a728d506c6dd2da47e2c21b Mon Sep 17 00:00:00 2001 From: Ronald Roy Date: Fri, 22 May 2026 14:12:44 -0700 Subject: [PATCH] Add configurable discussion model registry (#492) Replaces the hardcoded DiscussionModel enum and DISCUSSION_MODEL_MAP with a runtime registry loaded from memory-loop-config.json at daemon startup. Vaults now store a model name string that is resolved against the registry at session-creation time; unset vaults fall through to pi-agent's own default instead of always defaulting to opus. - New daemon/src/global-config.ts: registry loaded from MEMORY_LOOP_CONFIG env var or ${VAULTS_DIR}/memory-loop-config.json. Missing/malformed config is non-fatal; daemon warns and continues with empty registry. - New GET /models route (daemon + Next.js proxy) returns the current registry as { models: [{ name, provider, modelId }] }. - Removed DiscussionModelSchema, VALID_DISCUSSION_MODELS, DEFAULT_DISCUSSION_MODEL, and resolveDiscussionModel from shared. discussionModel is now plain string | undefined throughout. - ConfigEditorDialog model dropdown is now dynamic: fetches /api/models on mount, shows "No models configured." when empty, and renders " (unknown)" for values absent from the registry. - Test injection via configureRegistryForTesting / _resetRegistryForTesting (no mock.module(), which Bun prohibits). Co-Authored-By: Claude Sonnet 4.6 --- .../_infrastructure/configuration.md | 23 +- .lore/reference/think.md | 5 +- .../notes/configurable-discussion-models.html | 196 +++ ...mplify-configurable-discussion-models.html | 113 ++ .../plans/configurable-discussion-models.html | 1130 +++++++++++++++++ .../specs/configurable-discussion-models.html | 568 +++++++++ daemon/src/__tests__/global-config.test.ts | 85 ++ daemon/src/__tests__/session-manager.test.ts | 248 ++-- daemon/src/global-config.ts | 89 ++ daemon/src/index.ts | 34 +- daemon/src/router.ts | 8 +- daemon/src/routes/__tests__/models.test.ts | 33 + daemon/src/routes/models.ts | 11 + daemon/src/session-manager.ts | 335 +---- .../src/vault/__tests__/vault-config.test.ts | 67 +- daemon/src/vault/vault-config.ts | 232 ++-- daemon/src/vault/vault-manager.ts | 17 +- nextjs/app/api/models/route.ts | 7 + .../components/vault/ConfigEditorDialog.tsx | 195 +-- .../__tests__/ConfigEditorDialog.test.tsx | 86 +- nextjs/lib/daemon/index.ts | 1 + nextjs/lib/daemon/models.ts | 13 + packages/shared/src/index.ts | 21 +- packages/shared/src/schemas/index.ts | 61 +- packages/shared/src/schemas/protocol.ts | 533 ++------ packages/shared/src/schemas/types.ts | 80 +- packages/shared/src/vault-config.ts | 28 +- 27 files changed, 2807 insertions(+), 1412 deletions(-) create mode 100644 .lore/work/notes/configurable-discussion-models.html create mode 100644 .lore/work/notes/simplify-configurable-discussion-models.html create mode 100644 .lore/work/plans/configurable-discussion-models.html create mode 100644 .lore/work/specs/configurable-discussion-models.html create mode 100644 daemon/src/__tests__/global-config.test.ts create mode 100644 daemon/src/global-config.ts create mode 100644 daemon/src/routes/__tests__/models.test.ts create mode 100644 daemon/src/routes/models.ts create mode 100644 nextjs/app/api/models/route.ts create mode 100644 nextjs/lib/daemon/models.ts diff --git a/.lore/reference/_infrastructure/configuration.md b/.lore/reference/_infrastructure/configuration.md index 464c56ec..45e4b627 100644 --- a/.lore/reference/_infrastructure/configuration.md +++ b/.lore/reference/_infrastructure/configuration.md @@ -44,7 +44,7 @@ Configuration manages per-vault settings via `.memory-loop.json`. Each vault can | Field | Type | Default | Purpose | |-------|------|---------|---------| -| `discussionModel` | "opus" \| "sonnet" \| "haiku" | "opus" | Model for conversations | +| `discussionModel` | string (key from global model registry) | undefined (uses pi-agent fallback) | Model for conversations | ### Inspiration @@ -166,7 +166,7 @@ Zod schema enforces: - `recentDiscussions`: int, 1-20 - `badges`: array max 5, text max 20 chars - `order`: int, min 1 -- `discussionModel`: enum ["opus", "sonnet", "haiku"] +- `discussionModel`: any string; validation against registry happens at session creation. Invalid values return 400 error with message displayed inline in dialog. @@ -192,6 +192,25 @@ This means `VaultInfo` always has resolved values; the frontend doesn't need to | [Spaced Repetition](../spaced-repetition.md) | cardsEnabled | | [Think](../think.md) | discussionModel | +## Global Config File + +A daemon-level config file defines the model registry available across all vaults. + +**Path**: `MEMORY_LOOP_CONFIG` env var overrides, otherwise `${VAULTS_DIR}/memory-loop-config.json` + +**Format**: +```json +{ + "models": { + "": { "provider": "...", "modelId": "..." } + } +} +``` + +**Endpoint**: `GET /api/models` returns `{ "models": [{ "name": "...", "provider": "...", "modelId": "..." }] }` + +**Behavior when absent or malformed**: daemon logs a warning, continues with empty registry. `GET /api/models` returns `{ "models": [] }`. + ## Notes - Title/subtitle from config override CLAUDE.md extraction diff --git a/.lore/reference/think.md b/.lore/reference/think.md index 23c72370..11989ef8 100644 --- a/.lore/reference/think.md +++ b/.lore/reference/think.md @@ -183,8 +183,9 @@ Claude can ask structured questions: ## Model Selection **Config**: `.memory-loop.json` → `discussionModel` -**Options**: `"opus"` | `"sonnet"` | `"haiku"` -**Default**: `"opus"` +**Options**: any key defined in the global model registry (see Global Config) +**Default**: none — unset vaults fall back to the pi-agent default +**Resolution**: at session creation, the daemon looks up the vault's `discussionModel` string in the runtime registry. Unknown names log a warning and fall back to pi-agent default. Passed to Claude SDK when creating session. diff --git a/.lore/work/notes/configurable-discussion-models.html b/.lore/work/notes/configurable-discussion-models.html new file mode 100644 index 00000000..8cadb67e --- /dev/null +++ b/.lore/work/notes/configurable-discussion-models.html @@ -0,0 +1,196 @@ + + + + + Notes: configurable discussion model registry + + + + + + + + + +

Notes: configurable discussion model registry

+

Plan: configurable-discussion-models.html

+ + + +

Phase Progress

+ +
    +
  • +
    + done + Phase 1 — Create global-config.ts + tests +
    +
    +

    ✓ Created daemon/src/global-config.ts — registry with loadGlobalConfig, getRegistry, configureRegistryForTesting, _resetRegistryForTesting.

    +

    ✓ Created daemon/src/__tests__/global-config.test.ts — 5/5 tests pass.

    +

    ✓ Typecheck clean.

    +
    +
  • +
  • +
    + done + Phase 2 — Daemon startup wiring + GET /models route +
    +
    +

    ✓ Wired await loadGlobalConfig() after initVaultCache() in daemon/src/index.ts.

    +

    ✓ Created daemon/src/routes/models.ts with getModelsHandler.

    +

    ✓ Registered GET /models in daemon/src/router.ts.

    +

    ✓ Created daemon/src/routes/__tests__/models.test.ts — 2/2 tests pass.

    +

    ✓ Typecheck clean.

    +
    +
  • +
  • +
    + done + Phase 3 — Shared type purge (atomic breaking change) +
    +
    +

    ✓ Removed VALID_DISCUSSION_MODELS, DiscussionModelLocal, DEFAULT_DISCUSSION_MODEL, resolveDiscussionModel() from packages/shared/src/vault-config.ts.

    +

    ✓ Removed their exports from packages/shared/src/index.ts.

    +

    ✓ Removed DiscussionModelSchema, DiscussionModel from packages/shared/src/schemas/protocol.ts; updated both fields to z.string().optional().

    +

    ✓ Cascading: removed re-exports from packages/shared/src/schemas/index.ts; updated VaultInfo.discussionModel type in schemas/types.ts.

    +

    ✓ Simplified vault-config.ts enum guard to plain string check; updated vault-manager.ts to pass config.discussionModel directly.

    +

    ✓ Rewrote resolveModelForPiAgent() in session-manager.ts to use getRegistry() lookup; removed DISCUSSION_MODEL_MAP.

    +

    ✓ Cascading: Phase 3 agent also fixed vault-config.test.ts (removed deleted imports, updated discussionModel tests) and fixed ConfigEditorDialog.tsx local type.

    +

    ✓ Typecheck clean. grep shows zero matches for all removed symbols.

    +
    +
  • +
  • +
    + done + Phase 4 — Next.js layer (proxy route, daemon client, frontend) +
    +
    +

    ✓ Created nextjs/app/api/models/route.ts — proxy to daemon /models.

    +

    ✓ Created nextjs/lib/daemon/models.ts — ModelEntry interface + getModels().

    +

    ✓ Updated nextjs/lib/daemon/index.ts barrel export.

    +

    ✓ Updated ConfigEditorDialog.tsx: added availableModels state + useEffect fetch, dynamic select with unknown-model handling.

    +

    ✓ Typecheck clean.

    +
    +
  • +
  • +
    + done + Phase 5 — Update existing tests +
    +
    +

    ✓ session-manager.test.ts: added registry imports + _resetRegistryForTesting to afterEach; injected haiku registry for "uses vault config model" test; updated "no discussionModel" assertion to toBeUndefined().

    +

    ✓ ConfigEditorDialog.test.tsx: added global fetch mock (opus/sonnet/haiku); updated assertions from descriptive labels to plain name keys; added waitFor for async useEffect; added unknown-model test.

    +

    ✓ vault-config.test.ts already done by Phase 3 agent.

    +

    ✓ Full suite: 2042 tests, 0 failures.

    +
    +
  • +
  • +
    + done + Phase 6 — Documentation updates +
    +
    +

    ✓ configuration.md: updated discussionModel type/default, updated validation note, added Global Config File section.

    +

    ✓ think.md: updated Model Selection section to reference registry instead of hardcoded enum.

    +

    ✓ Typecheck clean.

    +
    +
  • +
+ +

Decisions & Divergences

+
    +
  • Pre-existing change to session-manager.ts: DISCUSSION_MODEL_MAP had model IDs updated (claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-6) and two fallback entries added (basic, text). Phase 3 removes DISCUSSION_MODEL_MAP entirely, so these changes are subsumed.
  • +
+ + + + diff --git a/.lore/work/notes/simplify-configurable-discussion-models.html b/.lore/work/notes/simplify-configurable-discussion-models.html new file mode 100644 index 00000000..c0070466 --- /dev/null +++ b/.lore/work/notes/simplify-configurable-discussion-models.html @@ -0,0 +1,113 @@ + + + + + Simplify: configurable discussion model registry + + + + + + + + + +

Simplify: configurable discussion model registry

+ + + +

Group A — Daemon core (7 files)

+
    +
  • runningdaemon/src/global-config.ts
  • +
  • runningdaemon/src/index.ts
  • +
  • runningdaemon/src/router.ts
  • +
  • runningdaemon/src/session-manager.ts
  • +
  • runningdaemon/src/routes/models.ts
  • +
  • runningdaemon/src/vault/vault-config.ts
  • +
  • runningdaemon/src/vault/vault-manager.ts
  • +
+ +

Group B — Daemon tests (4 files)

+
    +
  • runningdaemon/src/__tests__/global-config.test.ts
  • +
  • runningdaemon/src/__tests__/session-manager.test.ts
  • +
  • runningdaemon/src/vault/__tests__/vault-config.test.ts
  • +
  • runningdaemon/src/routes/__tests__/models.test.ts
  • +
+ +

Group C — Shared + Next.js (12 files)

+
    +
  • runningpackages/shared/src/index.ts
  • +
  • runningpackages/shared/src/schemas/index.ts
  • +
  • runningpackages/shared/src/schemas/protocol.ts
  • +
  • runningpackages/shared/src/schemas/types.ts
  • +
  • runningpackages/shared/src/vault-config.ts
  • +
  • runningnextjs/components/vault/ConfigEditorDialog.tsx
  • +
  • runningnextjs/components/vault/__tests__/ConfigEditorDialog.test.tsx
  • +
  • runningnextjs/lib/daemon/index.ts
  • +
  • runningnextjs/lib/daemon/models.ts
  • +
  • runningnextjs/app/api/models/route.ts
  • +
+ +

Test & Review

+
    +
  • pendingbun run test (full suite)
  • +
  • pendingcode review — behavior preservation check
  • +
+ + + + diff --git a/.lore/work/plans/configurable-discussion-models.html b/.lore/work/plans/configurable-discussion-models.html new file mode 100644 index 00000000..9e7947f9 --- /dev/null +++ b/.lore/work/plans/configurable-discussion-models.html @@ -0,0 +1,1130 @@ + + + + + Implementation plan: configurable discussion model registry + + + + + + + + + +

Implementation plan: configurable discussion model registry

+Source spec: REQ-MODELS-1 through REQ-MODELS-10 + +

+ Six phases. The first three are additive (new files, no deletions). Phase 3 is the breaking change: + it removes the shared enum and all its callers atomically. Run bun run typecheck after + each phase to confirm clean output before moving on. +

+ +

Affected files at a glance

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileActionPhase
daemon/src/global-config.tsnew1
daemon/src/__tests__/global-config.test.tsnew1
daemon/src/index.tsmod2
daemon/src/routes/models.tsnew2
daemon/src/routes/__tests__/models.test.tsnew2
daemon/src/router.tsmod2
packages/shared/src/schemas/protocol.tsmod3
packages/shared/src/vault-config.tsmod3
packages/shared/src/index.tsmod3
daemon/src/vault/vault-config.tsmod3
daemon/src/vault/vault-manager.tsmod3
daemon/src/session-manager.tsmod3
nextjs/app/api/models/route.tsnew4
nextjs/lib/daemon/models.tsnew4
nextjs/lib/daemon/index.tsmod4
nextjs/components/vault/ConfigEditorDialog.tsxmod4
daemon/src/__tests__/session-manager.test.tsmod5
daemon/src/vault/__tests__/vault-config.test.tsmod5
nextjs/components/vault/__tests__/ConfigEditorDialog.test.tsxmod5
.lore/reference/_infrastructure/configuration.mddoc6
.lore/reference/think.mddoc6
+ + + +
+
Phase 1
global-config
+
Phase 2
startup + route
+
Phase 3
shared purge
+
Phase 4
Next.js
+
Phase 5
test updates
+
Phase 6
docs
+
+ + + +
+

Phase 1 — Create global-config.ts

+

+ New isolated module. No existing code is touched. All subsequent phases depend on it. +

+ +
+
+ 1.1 +
+
Write daemon/src/global-config.ts
+
daemon/src/global-config.ts (new)
+
+ +
+
+

Create the module with these exports:

+
    +
  • ModelEntry = { provider: string; modelId: string } — interface for one registry entry.
  • +
  • ModelRegistry = Record<string, ModelEntry> — the full registry map.
  • +
  • Module-level let registry: ModelRegistry = {} — mutable state, not exported directly.
  • +
  • getRegistry(): ModelRegistry — returns current registry. Called by resolveModelForPiAgent() and the models route handler.
  • +
  • loadGlobalConfig(): Promise<void> — called once at startup. Reads the config file, populates registry.
  • +
  • configureRegistryForTesting(r: ModelRegistry): () => void — sets registry, returns cleanup function that resets it. Follows configurePiSessionForTesting pattern.
  • +
  • _resetRegistryForTesting(): void — direct reset to {}. Called in afterEach.
  • +
+

Config file resolution (inside loadGlobalConfig, not at module top)

+
function getConfigFilePath(): string {
+  return (
+    process.env.MEMORY_LOOP_CONFIG ??
+    join(getVaultsDir(), "memory-loop-config.json")
+  );
+}
+

+ getVaultsDir() is imported from the vault module (already exported). Call it inside the function, not at module import time — matching the pattern in vault-manager.ts so tests can set VAULTS_DIR in beforeEach. +

+

loadGlobalConfig logic

+
export async function loadGlobalConfig(): Promise<void> {
+  const path = getConfigFilePath();
+  let raw: string;
+  try {
+    raw = await readFile(path, "utf-8");
+  } catch {
+    log.warn(`Global config not found at ${path}, model registry is empty`);
+    registry = {};
+    return;
+  }
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(raw);
+  } catch {
+    log.warn(`Global config at ${path} is not valid JSON, model registry is empty`);
+    registry = {};
+    return;
+  }
+  if (typeof parsed !== "object" || parsed === null || !("models" in parsed)) {
+    registry = {};
+    return;
+  }
+  const modelsRaw = (parsed as Record<string, unknown>).models;
+  if (typeof modelsRaw !== "object" || modelsRaw === null) {
+    registry = {};
+    return;
+  }
+  const result: ModelRegistry = {};
+  for (const [name, entry] of Object.entries(modelsRaw)) {
+    if (
+      typeof entry === "object" && entry !== null &&
+      "provider" in entry && typeof (entry as Record<string, unknown>).provider === "string" &&
+      "modelId" in entry && typeof (entry as Record<string, unknown>).modelId === "string"
+    ) {
+      result[name] = entry as ModelEntry;
+    } else {
+      log.warn(`Skipping invalid model entry "${name}" in global config`);
+    }
+  }
+  registry = result;
+  log.info(`Loaded ${Object.keys(registry).length} model(s) from global config`);
+}
+
+ Unknown top-level keys (anything other than models) are silently ignored per REQ-MODELS-1. +
+
+
+ +
+
+ 1.2 +
+
Write daemon/src/__tests__/global-config.test.ts
+
daemon/src/__tests__/global-config.test.ts (new)
+
+ +
+
+

Five test cases matching REQ-MODELS-9:

+
    +
  1. File not found → getRegistry() returns {}, no throw.
  2. +
  3. Malformed JSON → getRegistry() returns {}, no throw.
  4. +
  5. Valid file with three entries → registry populated with all three, correct shape.
  6. +
  7. File with one malformed entry (missing modelId) and one valid → valid entry retained, malformed skipped.
  8. +
  9. Empty models object ({"models":{}}) → registry is {}.
  10. +
+

+ Use a temp dir for config file writes. Set MEMORY_LOOP_CONFIG env var in beforeEach to point at the temp file path, clear it in afterEach. Call _resetRegistryForTesting() in every afterEach. +

+
+
+ +
+

Phase 1 gate

+
    +
  1. bun run --cwd daemon test src/__tests__/global-config.test.ts — all 5 tests pass.
  2. +
  3. bun run typecheck — zero errors.
  4. +
+
+
+ + + +
+

Phase 2 — Daemon startup wiring and GET /models route

+

+ Still additive. No existing behavior is removed. After this phase, GET /api/models + is functional even before the shared type purge. +

+ +
+
+ 2.1 +
+
Wire loadGlobalConfig() into daemon startup
+
daemon/src/index.ts (mod)
+
+ +
+
+

+ In daemon/src/index.ts, add an import { loadGlobalConfig } from "./global-config". +

+

+ Call await loadGlobalConfig() after await initVaultCache() (line ~40) and before startServer() (line ~70). Position matters: the registry must be populated before the HTTP server accepts requests. +

+
await initVaultCache();
+await loadGlobalConfig();   // ← add this line
+// ... background schedulers ...
+startServer();
+

+ The call is fire-and-forget-safe: loadGlobalConfig() never throws; a missing or bad config file is handled internally with a warning log. +

+
+
+ +
+
+ 2.2 +
+
Create daemon/src/routes/models.ts
+
daemon/src/routes/models.ts (new)
+
+ +
+
+

Single exported handler:

+
import type { Context } from "hono";
+import { getRegistry } from "../global-config";
+
+export function getModelsHandler(c: Context) {
+  const registry = getRegistry();
+  const models = Object.entries(registry).map(([name, entry]) => ({
+    name,
+    provider: entry.provider,
+    modelId: entry.modelId,
+  }));
+  return c.json({ models });
+}
+

+ Order matches V8/Bun insertion order of JSON.parse keys (non-integer string keys preserve insertion order). No sorting needed per REQ-MODELS-3. +

+
+
+ +
+
+ 2.3 +
+
Register GET /models in router
+
daemon/src/router.ts (mod)
+
+ +
+
+

+ Add the import and registration to registerRoutes(). Follow the existing pattern (other GET routes near the top of the function): +

+
import { getModelsHandler } from "./routes/models";
+
+// inside registerRoutes():
+app.get("/models", getModelsHandler);
+

Place it near the other resource-listing GET routes (e.g., alongside GET /vaults).

+
+
+ +
+
+ 2.4 +
+
Write daemon/src/routes/__tests__/models.test.ts
+
daemon/src/routes/__tests__/models.test.ts (new)
+
+ +
+
+

Two test cases per REQ-MODELS-9:

+
    +
  1. + Empty registry (_resetRegistryForTesting() only) → GET /models returns + 200 with { models: [] }. +
  2. +
  3. + Registry populated via configureRegistryForTesting({ opus: { provider: "anthropic", modelId: "claude-opus-4-7" } }) + → response is { models: [{ name: "opus", provider: "anthropic", modelId: "claude-opus-4-7" }] }. +
  4. +
+

+ Follow the pattern in daemon/src/routes/__tests__/health.test.ts: create a Hono test app, + register just the models route, hit it with a Request object. +

+

Call _resetRegistryForTesting() in afterEach.

+
+
+ +
+

Phase 2 gate

+
    +
  1. bun run --cwd daemon test src/routes/__tests__/models.test.ts — both tests pass.
  2. +
  3. bun run typecheck — zero errors.
  4. +
+
+
+ + + +
+

Phase 3 — Shared type purge (breaking change, all callers fixed atomically)

+

+ This phase removes the hardcoded enum and updates every caller. Do all steps before running typecheck; + the intermediate state will not compile. +

+
+ REQ-MODELS-4 drives steps 3.1 and 3.2. REQ-MODELS-5 and REQ-MODELS-8 drive steps 3.3 through 3.5. +
+ +
+
+ 3.1 +
+
Remove enum, type, and helpers from packages/shared/src/vault-config.ts
+
packages/shared/src/vault-config.ts (mod)
+
+ +
+
+

Remove the following from packages/shared/src/vault-config.ts:

+
    +
  • VALID_DISCUSSION_MODELS constant (line ~49)
  • +
  • type DiscussionModelLocal (line ~50)
  • +
  • DEFAULT_DISCUSSION_MODEL constant (line ~51)
  • +
  • resolveDiscussionModel() function (lines ~127-129)
  • +
+

+ The VaultConfig interface's discussionModel?: string field + stays as-is — it's already typed as plain string | undefined. +

+

+ Also edit packages/shared/src/index.ts (the barrel export) to remove + the named exports for all four deleted symbols: + VALID_DISCUSSION_MODELS, DiscussionModelLocal, + DEFAULT_DISCUSSION_MODEL, and resolveDiscussionModel. + Leaving them in the barrel export while deleting the definitions will cause a typecheck error. +

+
+
+ +
+
+ 3.2 +
+
Remove enum and type from packages/shared/src/schemas/protocol.ts
+
packages/shared/src/schemas/protocol.ts (mod)
+
+ +
+
+

Three changes:

+
    +
  1. + Delete DiscussionModelSchema = z.enum(["opus", "sonnet", "haiku"]) (line ~52). +
  2. +
  3. + In EditableVaultConfigSchema: change discussionModel: DiscussionModelSchema.optional() + to discussionModel: z.string().optional(). +
  4. +
  5. + In VaultInfoSchema: change discussionModel: DiscussionModelSchema.optional() + to discussionModel: z.string().optional(). +
  6. +
  7. + Delete the exported type type DiscussionModel = z.infer<typeof DiscussionModelSchema> + (line ~1082). +
  8. +
+
+
+ +
+
+ 3.3 +
+
Remove enum validation from daemon/src/vault/vault-config.ts
+
daemon/src/vault/vault-config.ts (mod)
+
+ +
+
+

+ In loadVaultConfig(), the block at lines ~69-74 currently does this: +

+
if (
+  typeof obj.discussionModel === "string" &&
+  VALID_DISCUSSION_MODELS.includes(obj.discussionModel as ...)
+) {
+  config.discussionModel = obj.discussionModel;
+}
+

+ Replace this block (do not delete it) with a simpler check that accepts any string: +

+
if (typeof obj.discussionModel === "string") {
+  config.discussionModel = obj.discussionModel;
+}
+

+ Deleting the block entirely would silently drop discussionModel from all loaded + vault configs. Non-string values (e.g. 123) are still ignored — the z.string() + guard does that job. The enum whitelist check is the only thing removed. +

+

Remove the import of VALID_DISCUSSION_MODELS from shared.

+
+ The vault-config test currently has a test "ignores invalid discussionModel value" that + expects non-enum strings to be rejected. After this change, any string is valid; update + that test to confirm the string is passed through rather than dropped. The test "ignores + non-string discussionModel value" (e.g. 123) remains correct — z.string() + still rejects non-strings. See Phase 5 for exact test edits. +
+
+
+ +
+
+ 3.4 +
+
Update parseVault() in daemon/src/vault/vault-manager.ts
+
daemon/src/vault/vault-manager.ts (mod)
+
+ +
+
+

+ In parseVault() at line ~169, change: +

+
// before
+discussionModel: resolveDiscussionModel(config),
+
+// after
+discussionModel: config.discussionModel,
+

+ Remove the import { resolveDiscussionModel } from @memory-loop/shared. + A vault with no discussionModel set now returns undefined in the + VaultInfo response instead of the hardcoded "opus" default. + This is the intentional behavior change described in the spec. +

+
+
+ +
+
+ 3.5 +
+
Rewrite resolveModelForPiAgent() in daemon/src/session-manager.ts
+
daemon/src/session-manager.ts (mod)
+
+ +
+
+

Two deletions and one rewrite:

+
    +
  1. Delete DISCUSSION_MODEL_MAP constant (lines ~65-71).
  2. +
  3. Remove the import of resolveDiscussionModel from @memory-loop/shared.
  4. +
  5. Rewrite resolveModelForPiAgent():
  6. +
+
function resolveModelForPiAgent(
+  vaultPath: string,
+  config: Awaited<ReturnType<typeof loadVaultConfig>>
+): PiAgentModel | undefined {
+  const modelName = config.discussionModel;
+  if (!modelName) return undefined;
+
+  const entry = getRegistry()[modelName];
+  if (!entry) {
+    log.warn(`Unknown discussion model "${modelName}" for vault at ${vaultPath}, using pi-agent fallback`);
+    return undefined;
+  }
+  return entry;
+}
+

Add import at top of file:

+
import { getRegistry } from "./global-config";
+

+ The openPiSessionForVault() caller (lines ~679-701) needs no changes — it already + handles model being undefined and logs appropriately. +

+
+
+ +
+

Phase 3 gate

+
    +
  1. bun run typecheck — zero errors. Confirm with grep: + grep -r "DiscussionModelLocal\|DiscussionModelSchema\|VALID_DISCUSSION_MODELS\|DISCUSSION_MODEL_MAP" packages/ daemon/src/ + → zero matches. +
  2. +
  3. bun run lint — zero errors.
  4. +
+
+
+ + + +
+

Phase 4 — Next.js layer (proxy route, daemon client, frontend)

+

Three independent changes that can be written in any order within the phase.

+ +
+
+ 4.1 +
+
Create nextjs/app/api/models/route.ts
+
nextjs/app/api/models/route.ts (new)
+
+ +
+
+

Minimal proxy following the pattern in nextjs/app/api/vaults/[vaultId]/config/route.ts:

+
import { NextResponse } from "next/server";
+import { daemonFetch } from "@/lib/daemon/fetch";
+
+export async function GET() {
+  const res = await daemonFetch("/models");
+  return NextResponse.json(await res.json(), { status: res.status });
+}
+

+ No auth wrapper needed — consistent with other read-only daemon proxy routes. The browser calls + GET /api/models; this handler forwards to the daemon and relays the response. +

+
+
+ +
+
+ 4.2 +
+
Create nextjs/lib/daemon/models.ts and update barrel export
+
nextjs/lib/daemon/models.ts (new), nextjs/lib/daemon/index.ts (mod)
+
+ +
+
+

New file nextjs/lib/daemon/models.ts:

+
import { daemonFetch } from "./fetch";
+
+export interface ModelEntry {
+  name: string;
+  provider: string;
+  modelId: string;
+}
+
+export async function getModels(): Promise<ModelEntry[]> {
+  const res = await daemonFetch("/models");
+  const json = await res.json() as { models: ModelEntry[] };
+  return json.models;
+}
+

+ Add to nextjs/lib/daemon/index.ts: +

+
export * from "./models";
+
+
+ +
+
+ 4.3 +
+
Update model dropdown in ConfigEditorDialog.tsx
+
nextjs/components/vault/ConfigEditorDialog.tsx (mod)
+
+ +
+
+

The current dropdown (lines ~547-574) is hardcoded. Replace with a dynamic fetch.

+ +

State and fetch

+
const [availableModels, setAvailableModels] = useState<ModelEntry[]>([]);
+
+useEffect(() => {
+  fetch("/api/models")
+    .then((r) => r.json())
+    .then((data: { models: ModelEntry[] }) => setAvailableModels(data.models))
+    .catch(() => setAvailableModels([]));
+}, []);
+

Import ModelEntry from @/lib/daemon/models.

+ +

Dropdown markup

+
<select
+  id={discussionModelId}
+  className="config-editor__select"
+  value={formState.discussionModel ?? ""}
+  onChange={(e) => setFormState(prev => ({
+    ...prev,
+    discussionModel: e.target.value || undefined,
+  }))}
+>
+  {availableModels.length === 0 ? (
+    <option value="" disabled>No models configured.</option>
+  ) : (
+    <>
+      <option value="">— unset —</option>
+      {/* Current value not in list: show it with (unknown) indicator */}
+      {formState.discussionModel &&
+        !availableModels.some(m => m.name === formState.discussionModel) && (
+        <option value={formState.discussionModel}>
+          {formState.discussionModel} (unknown)
+        </option>
+      )}
+      {availableModels.map(m => (
+        <option key={m.name} value={m.name}>{m.name}</option>
+      ))}
+    </>
+  )}
+</select>
+ +

Type cleanup

+

+ Remove any remaining references to the "opus" | "sonnet" | "haiku" literal type + cast in the onChange handler (the current cast on line ~570). The field is now + string | undefined per the updated schema. +

+ +
+ REQ-MODELS-6: when the stored value is not in the API list, it renders with " (unknown)" and + remains selectable and saveable. The select's value will match the option + added above, so the browser renders it correctly without custom styling. +
+
+
+ +
+

Phase 4 gate

+
    +
  1. bun run typecheck — zero errors.
  2. +
  3. bun run lint — zero errors.
  4. +
  5. Manual smoke test: bun run daemon:dev + bun run --cwd nextjs dev. + Open vault settings. Confirm dropdown is empty ("No models configured.") when no config file is present.
  6. +
  7. Write ${VAULTS_DIR}/memory-loop-config.json with two model entries, restart daemon. + Reopen vault settings. Confirm dropdown shows both model names.
  8. +
+
+
+ + + +
+

Phase 5 — Update existing tests

+

+ Two existing test files need updates. No new behavior is tested here — only the tests that + depended on the removed constants and the old default-model behavior. +

+ +
+
+ 5.1 +
+
Update daemon/src/__tests__/session-manager.test.ts
+
daemon/src/__tests__/session-manager.test.ts (mod)
+
+ +
+
+

Imports

+

Add to the import block:

+
import {
+  configureRegistryForTesting,
+  _resetRegistryForTesting,
+} from "../global-config";
+ +

afterEach

+

Add _resetRegistryForTesting() to the existing afterEach:

+
afterEach(async () => {
+  _resetPiSessionForTesting();
+  _resetRegistryForTesting();   // ← add this
+  await rm(tempDir, { recursive: true, force: true });
+});
+ +

Test: "uses vault config model when present" (line ~173)

+

+ Before the test body, inject a registry with the haiku entry. The assertion value must match + whatever is in the injected registry (not the old hardcoded map): +

+
test("uses vault config model when present", async () => {
+  configureRegistryForTesting({
+    haiku: { provider: "anthropic", modelId: "claude-haiku-4-5" },
+  });
+  await writeFile(
+    join(tempDir, ".memory-loop.json"),
+    JSON.stringify({ discussionModel: "haiku" })
+  );
+  // ... rest of test unchanged ...
+  expect(capturedOpts.model).toEqual({ provider: "anthropic", modelId: "claude-haiku-4-5" });
+});
+ +

Test: "passes no model when vault config has no discussionModel" (line ~193)

+

+ This is the deliberate behavior change per REQ-MODELS-9. No registry injection needed + (empty registry by default). Update the assertion: +

+
// before
+expect(capturedOpts.model).toEqual({ provider: "anthropic", modelId: "claude-opus-4-5" });
+
+// after
+expect(capturedOpts.model).toBeUndefined();
+

Also update the test name to reflect the new behavior (optional but keeps the comment accurate).

+
+
+ +
+
+ 5.2 +
+
Update daemon/src/vault/__tests__/vault-config.test.ts
+
daemon/src/vault/__tests__/vault-config.test.ts (mod)
+
+ +
+
+

Remove deleted imports

+
// Remove these imports (line 30, 42):
+import { VALID_DISCUSSION_MODELS } from "@memory-loop/shared";
+import { resolveDiscussionModel } from "@memory-loop/shared";
+ +

Remove test for VALID_DISCUSSION_MODELS constant (line ~91)

+

Delete the entire test block that asserts the constant equals ["opus", "sonnet", "haiku"].

+ +

Update "discussionModel" test suite (lines ~244-266)

+

Three specific changes:

+
    +
  1. + "loads valid discussionModel" parameterized test: the current set of valid values + is just ["opus", "sonnet", "haiku"]. After the change, any string is valid. + Keep the test but expand or simplify to test a couple of arbitrary strings (e.g. "opus", + "custom-model", "some-other-string"). +
  2. +
  3. + "ignores invalid discussionModel value": remove this test entirely. + A string that was previously "invalid" (i.e., not in the enum) is now a valid string. + There is no longer a concept of an invalid string value. +
  4. +
  5. + "ignores non-string discussionModel value" (line ~262): keep as-is. + The Zod schema still rejects non-strings (e.g. 123). +
  6. +
+ +

Remove resolveDiscussionModel test suite (lines ~500-507)

+

Delete the entire describe("resolveDiscussionModel", ...) block. The function no longer exists.

+
+
+ +
+
+ 5.3 +
+
Update nextjs/components/vault/__tests__/ConfigEditorDialog.test.tsx
+
nextjs/components/vault/__tests__/ConfigEditorDialog.test.tsx (mod)
+
+ +
+
+

+ The dialog's dropdown now fetches from /api/models in a useEffect. + In a jsdom test environment, fetch either fails or needs to be mocked. + Without a mock, availableModels will be [] and the dropdown + renders "No models configured." rather than the hardcoded options. All existing assertions + about the model select element will fail. +

+ +

Mock strategy

+

+ Use a global fetch mock at the top of the test file. The dialog fetches + /api/models once on mount. Intercept it and return a controlled model list: +

+
const mockFetch = mock(async (url: string) => {
+  if (url === "/api/models") {
+    return new Response(
+      JSON.stringify({ models: [
+        { name: "opus",   provider: "anthropic", modelId: "claude-opus-4-7" },
+        { name: "sonnet", provider: "anthropic", modelId: "claude-sonnet-4-6" },
+        { name: "haiku",  provider: "anthropic", modelId: "claude-haiku-4-5" },
+      ]}),
+      { headers: { "Content-Type": "application/json" } }
+    );
+  }
+  throw new Error(`Unexpected fetch: ${url}`);
+});
+
+beforeAll(() => { global.fetch = mockFetch as typeof fetch; });
+afterAll(() => { jest.restoreAllMocks(); });
+ +

Assertion updates

+
    +
  • + Any assertion like expect(modelSelect.value).toBe("sonnet") remains valid if + the mock returns "sonnet" as an available option and the initial config has discussionModel: "sonnet". + The select value comes from formState.discussionModel (unchanged), and the matching + option will be present because the mock returns it. +
  • +
  • + Remove assertions like expect(screen.getByText("Opus (Most capable)")).toBeDefined(). + The new option labels are plain model name keys (e.g. "opus"), not descriptive strings. + Replace with expect(screen.getByRole("option", { name: "opus" })).toBeDefined(). +
  • +
  • + Any test that changes the select to a model name (e.g. "opus") and saves should still pass + because the option exists in the mocked list. +
  • +
  • + Add one new test: render with initialConfig.discussionModel: "unknown-model", + confirm the select shows "unknown-model (unknown)" as an option and it is selected. +
  • +
+
+
+ +
+

Phase 5 gate

+
    +
  1. bun run test — full suite passes. All existing tests that depended on the + hardcoded structures are updated, not deleted.
  2. +
  3. Confirm zero skipped tests related to model resolution.
  4. +
+
+
+ + + +
+

Phase 6 — Documentation updates

+ +
+
+ 6.1 +
+
Update .lore/reference/_infrastructure/configuration.md
+
.lore/reference/_infrastructure/configuration.md (mod)
+
+ +
+
+

Three edits:

+
    +
  1. + In the "AI Model" settings table, change the discussionModel type from + "opus" | "sonnet" | "haiku" and default "opus" to + string (key from global model registry) and default + undefined (uses pi-agent fallback). +
  2. +
  3. + In the "Validation" section, remove the line that lists discussionModel: enum ["opus", "sonnet", "haiku"]. + Replace with: discussionModel: any string; validation against registry happens at session creation. +
  4. +
  5. + Add a new section "Global Config File" describing: +
      +
    • Path: MEMORY_LOOP_CONFIG env var, or ${VAULTS_DIR}/memory-loop-config.json
    • +
    • Format: { "models": { "name": { "provider": "...", "modelId": "..." } } }
    • +
    • The new GET /api/models endpoint and its response shape
    • +
    • Behavior when the file is absent or malformed
    • +
    +
  6. +
+
+
+ +
+
+ 6.2 +
+
Update .lore/reference/think.md
+
.lore/reference/think.md (mod)
+
+ +
+
+

In the "Model Selection" section:

+
# before
+**Options**: `"opus"` | `"sonnet"` | `"haiku"`
+**Default**: `"opus"`
+Passed to Claude SDK when creating session.
+
+# after
+**Options**: any key defined in the global model registry (see Global Config)
+**Default**: none — unset vaults fall back to the pi-agent default (`("fallback", "text")`)
+**Resolution**: at session creation time, the daemon looks up the vault's `discussionModel`
+string in the runtime registry. Unknown names log a warning and fall back to pi-agent default.
+
+
+
+ + + +
+

Final validation (mirrors spec validation section)

+

Run these after all six phases are complete. They are drawn from the spec's AI Validation checklist.

+ +

Static checks

+
    +
  1. + bun run typecheck passes with zero errors. +
  2. +
  3. + grep -r "DiscussionModelLocal\|DiscussionModelSchema\|VALID_DISCUSSION_MODELS\|DISCUSSION_MODEL_MAP" packages/ daemon/src/ + → zero matches. +
  4. +
  5. bun run lint passes with zero errors.
  6. +
  7. bun run test passes. No tests deleted; the two modified session-manager tests + still cover the same behavior via the injectable registry.
  8. +
+ +

Runtime behavior

+
    +
  1. Start the daemon with no global config file. Logs show a warning about missing config, + no fatal error, daemon accepts requests. GET /api/models returns { "models": [] }.
  2. +
  3. Write a valid memory-loop-config.json with two model entries. + Restart daemon. GET /api/models returns both entries in insertion order.
  4. +
  5. Add an entry with a missing modelId to the config. Restart. + Logs show one "Skipping invalid model entry" warning. Valid entries still appear in GET /api/models.
  6. +
  7. Configure a vault with a model name in the registry. Start a discussion. + Logs contain provider and modelId from the registry entry.
  8. +
  9. Configure a vault with a model name NOT in the registry. Start a discussion. + Logs show "Unknown discussion model" warning. Session completes without error.
  10. +
  11. Leave discussionModel unset on a vault. Start a discussion. + No "Unknown discussion model" warning in logs. Session completes.
  12. +
+ +

Frontend

+
    +
  1. Open vault settings. Dropdown options match exactly what GET /api/models returns.
  2. +
  3. With empty registry, open vault settings. Dropdown shows "No models configured." Saving + leaves the vault with discussionModel: undefined.
  4. +
  5. Manually set a vault's .memory-loop.json to a model name not in the registry. + Open vault settings. The stored name appears with "(unknown)" appended.
  6. +
+
+ + + + diff --git a/.lore/work/specs/configurable-discussion-models.html b/.lore/work/specs/configurable-discussion-models.html new file mode 100644 index 00000000..80fce1c7 --- /dev/null +++ b/.lore/work/specs/configurable-discussion-models.html @@ -0,0 +1,568 @@ + + + + + Configurable discussion model registry + + + + + + + + + + +
+ Configurable discussion model registry + draft + prefix: + MODELS +
+ +

Configurable discussion model registry

+ +

+ Model names like "opus", "sonnet", "haiku" are currently hardcoded in two places: + a Zod enum in packages/shared and a translation map in daemon/src/session-manager.ts. + Adding, renaming, or repointing a model (e.g. to a different provider) requires a code change and a redeploy. +

+

+ This spec replaces those hardcoded structures with a runtime registry loaded from a global config file. + The config file maps arbitrary names to { provider, modelId } pairs. The daemon reads it on startup. + Per-vault config continues to reference models by name; the dropdown in the UI is populated from whatever + names are in the registry at runtime. +

+ +
+ Behavior change: Vaults with no discussionModel set currently use Opus + (the hardcoded default). After this change they use the pi-agent fallback. Sites that want Opus as + the default must configure their vaults or set discussionModel explicitly in + .memory-loop.json. +
+ +
+ Out of scope: Hot-reloading the model registry without a daemon restart. The registry is + read once on startup. A restart is acceptable and already required for other daemon config changes. +
+ +

Requirements

+ + +
+
+ REQ-MODELS-1 + Global config file defines the model registry + pending + +
+
+

+ A global config file for the memory-loop daemon defines a models object. + Each key is a model name (arbitrary string); each value is a { provider, modelId } pair. +

+
{
+  "models": {
+    "opus":   { "provider": "anthropic", "modelId": "claude-opus-4-7" },
+    "sonnet": { "provider": "anthropic", "modelId": "claude-sonnet-4-6" },
+    "haiku":  { "provider": "anthropic", "modelId": "claude-haiku-4-5" }
+  }
+}
+

+ The file path is determined by the env var MEMORY_LOOP_CONFIG, defaulting to + ${VAULTS_DIR}/memory-loop-config.json when unset. The path is resolved when + loadGlobalConfig() is called, not at module import time — mirroring the pattern + in vault-manager.ts where getVaultsDir() is called inside functions + so that test beforeEach can set VAULTS_DIR before the path is resolved. +

+

+ The file is optional: if absent, the model registry is empty and all sessions fall back + to the pi-agent default (REQ-MODELS-7). +

+

Unknown top-level keys in the file are ignored (forward-compatible).

+
+
+ + +
+
+ REQ-MODELS-2 + Daemon loads the model registry once on startup + pending + +
+
+

+ During daemon startup (daemon/src/index.ts), the global config is loaded before + the HTTP server accepts requests. The loaded model registry is stored as module-level state + in a new daemon/src/global-config.ts module, accessed via a getRegistry() + export. +

+

+ The module exports a configureRegistryForTesting(registry) / + _resetRegistryForTesting() pair following the same pattern as + pi-session-factory.ts. This allows tests to inject a mock registry without + using mock.module(), which is prohibited in this codebase. +

+

+ A missing or malformed config file is not a fatal error. The daemon logs a warning and + continues with an empty registry. A valid file with zero model entries is also accepted. +

+

+ Individual model entries with an invalid shape (missing provider or modelId, + non-string values) are skipped with a warning; valid entries in the same file are retained. +

+
+
+ + +
+
+ REQ-MODELS-3 + Model registry exposed via REST API + pending + +
+
+

+ A new daemon endpoint GET /api/models returns the runtime model registry as a + JSON array of entries, suitable for populating a dropdown. Order matches the insertion order + of keys as parsed by JSON.parse (preserved by V8/Bun for non-integer keys). +

+
// Response shape
+{
+  "models": [
+    { "name": "opus",   "provider": "anthropic", "modelId": "claude-opus-4-7" },
+    { "name": "sonnet", "provider": "anthropic", "modelId": "claude-sonnet-4-6" },
+    { "name": "haiku",  "provider": "anthropic", "modelId": "claude-haiku-4-5" }
+  ]
+}
+

+ When the registry is empty (no config file or empty models object), the response + is { "models": [] }. The frontend handles this gracefully (REQ-MODELS-6). +

+

+ A Next.js proxy route nextjs/app/api/models/route.ts forwards + GET /api/models to the daemon, consistent with all other daemon-proxied + endpoints. The browser calls the Next.js proxy; the proxy calls the daemon via Unix socket. +

+
+
+ + +
+
+ REQ-MODELS-4 + Remove hardcoded model enum and translation map + pending + +
+
+

The following hardcoded structures are removed:

+
    +
  • VALID_DISCUSSION_MODELS constant in packages/shared/src/vault-config.ts
  • +
  • DiscussionModelLocal type alias ("opus" | "sonnet" | "haiku")
  • +
  • DEFAULT_DISCUSSION_MODEL constant (no longer meaningful without a fixed set)
  • +
  • DISCUSSION_MODEL_MAP in daemon/src/session-manager.ts, including + its "basic" and "text" fallback-provider entries. Those entries + mapped to ("fallback", "basic") and ("fallback", "text") respectively. + Any vault that stored discussionModel: "text" will now hit the warning-then-fallback + path in REQ-MODELS-5, which resolves to ("fallback", "text") anyway — functionally + equivalent, one extra warning log line.
  • +
  • The DiscussionModelSchema Zod enum in packages/shared/src/schemas/protocol.ts
  • +
  • The DiscussionModel type export from shared
  • +
+

+ VaultConfig.discussionModel and EditableVaultConfig.discussionModel + both become string | undefined (or z.string().optional() in Zod). + The per-vault config stores the model name as a plain string. Validation against the registry + happens at session-creation time, not at config-save time (REQ-MODELS-5). +

+

+ resolveDiscussionModel(config) is removed. Call sites read + config.discussionModel directly, treating undefined as "use fallback." +

+
+
+ + +
+
+ REQ-MODELS-5 + Session creation resolves model from runtime registry + pending + +
+
+

+ In session-manager.ts, resolveModelForPiAgent() is rewritten to + look up the vault config's discussionModel name in the runtime registry loaded + by global-config.ts. +

+

If the name is found, its { provider, modelId } is passed to createPiSession().

+

+ If the name is not found in the registry (unknown name, or registry is empty), a warning is + logged and undefined is passed as the model option to + createPiSession(). The existing fallback in pi-session-factory.ts + then applies: pi-agent tries ("fallback", "text") and throws only if that is also + unavailable. +

+

+ If discussionModel is unset on the vault config, the same fallback path applies + without logging a warning. +

+
+
+ + +
+
+ REQ-MODELS-6 + Frontend model dropdown populated from API + pending + +
+
+

+ The model dropdown in ConfigEditorDialog.tsx is populated by fetching + GET /api/models rather than from a hardcoded list. The display label for each + option is the model name key (e.g. "opus"). No user-friendly description is + generated — the name is the label. +

+

+ When the API returns an empty list, the dropdown shows a single disabled option: + "No models configured." Saving the vault config with no model selected sets + discussionModel to undefined. +

+

+ If a vault's stored discussionModel value is not present in the list returned + by the API (e.g. registry was edited since the vault was configured), the dropdown shows + the stored value as the currently selected option, styled to indicate it is unrecognized + (e.g. appended with " (unknown)"). It remains selectable and saveable without change. +

+

+ The daemon client in nextjs/lib/daemon/ gains a getModels() function. +

+
+
+ + +
+
+ REQ-MODELS-7 + Fallback is pi-agent default, not a hardcoded pair + pending + +
+
+

+ When no model is resolved from the registry, undefined is passed as the + model option to createPiSession(). The factory's existing fallback + (FALLBACK_MODEL = { provider: "fallback", modelId: "text" }) handles the rest. +

+

+ The FALLBACK_MODEL constant in pi-session-factory.ts is not removed + or changed. It remains the last-resort safety net inside the factory, independent of whether + the model registry is populated. +

+

+ No default model name is baked into the daemon. If a site wants Opus as the default, + it configures vaults accordingly. If all vaults are unconfigured, they all fall back to + the pi-agent default silently. +

+
+
+ + +
+
+ REQ-MODELS-8 + VaultInfo no longer carries discussionModel as a typed enum + pending + +
+
+

+ VaultInfoSchema in packages/shared/src/schemas/protocol.ts changes + discussionModel from a DiscussionModelSchema enum to + z.string().optional(). +

+

+ parseVault() in vault-manager.ts passes the raw string through + directly rather than calling resolveDiscussionModel(). A vault with no + discussionModel set sends undefined in the VaultInfo response. +

+

+ The frontend stores and displays the vault's discussionModel as a string. + The typed DiscussionModel type export from shared is removed. +

+
+
+ + +
+
+ REQ-MODELS-9 + Tests cover the new global config module + pending + +
+
+

Unit tests for daemon/src/global-config.ts cover:

+
    +
  • File not found → empty registry, no throw
  • +
  • Malformed JSON → empty registry, no throw
  • +
  • Valid file → registry populated correctly
  • +
  • File with some invalid entries → valid entries retained, invalid entries skipped
  • +
  • Empty models object → empty registry
  • +
+

+ Existing session-manager tests are updated to inject a mock registry via + configureRegistryForTesting(). One existing test requires a semantic assertion + change: the test currently named "passes no model when vault config has no discussionModel" + currently asserts capturedOpts.model equals the Opus mapping (because + resolveDiscussionModel fills in the default). After this change, when + discussionModel is undefined, no registry lookup occurs and + capturedOpts.model must be undefined. Update the assertion + accordingly — this is a deliberate behavior change, not a bug. +

+

+ A unit test for the GET /api/models route covers empty registry and populated registry responses. +

+
+
+ + +
+
+ REQ-MODELS-10 + Reference documentation updated + pending + +
+
+

The following reference docs are updated to reflect the new behavior:

+
    +
  • + .lore/reference/_infrastructure/configuration.md: add the global config file, + its location, format, and the new GET /api/models endpoint. Update the + discussionModel field description from "enum" to "string key from global registry." +
  • +
  • + .lore/reference/think.md: update the Model Selection section to describe + runtime registry lookup rather than enum validation. +
  • +
+
+
+ +
+

AI Validation

+

After implementation, verify correctness by running the following checks in order.

+ +

Static checks

+
    +
  1. + bun run typecheck passes with zero errors. Confirm DiscussionModelLocal, + DiscussionModelSchema, and VALID_DISCUSSION_MODELS no longer exist + anywhere in the codebase: grep -r "DiscussionModelLocal\|DiscussionModelSchema\|VALID_DISCUSSION_MODELS\|DISCUSSION_MODEL_MAP" packages/ daemon/src/ → no matches. +
  2. +
  3. + bun run lint passes with zero errors. +
  4. +
  5. + bun run test passes. All existing tests that depended on the hardcoded + map/enum are updated (not deleted) and still test the same behavior via the injectable registry. +
  6. +
+ +

Config load behavior

+
    +
  1. + Start the daemon with no global config file present. Confirm in the logs: + no fatal error, warning is logged about missing config, daemon accepts requests normally. +
  2. +
  3. + Write a valid memory-loop-config.json with two model entries. + Restart the daemon. Confirm GET /api/models returns both entries in order. +
  4. +
  5. + Add an entry with a missing modelId field to the config file. + Restart. Confirm the bad entry is skipped (warning in logs), valid entries still present + in GET /api/models. +
  6. +
+ +

Session behavior

+
    +
  1. + Configure a vault with a model name that exists in the registry. Start a discussion session. + Confirm a log line from [Session] or [PiSessionFactory] contains + the expected provider and modelId values. +
  2. +
  3. + Configure a vault with a model name that does NOT exist in the registry. Start a discussion. + Confirm: a warning containing "Unknown discussion model" appears in daemon logs; session + completes without error (falls through to pi-agent default). +
  4. +
  5. + Leave discussionModel unset on a vault. Start a discussion. + Confirm: no "Unknown discussion model" warning in daemon logs; session completes. +
  6. +
+ +

Frontend behavior

+
    +
  1. + Open the vault settings dialog. Confirm the model dropdown options match exactly + the names returned by GET /api/models. +
  2. +
  3. + With an empty registry ({ "models": {} } or no config file), open the dialog. + Confirm the dropdown shows "No models configured." and saving leaves the vault with + discussionModel: undefined. +
  4. +
  5. + Manually set a vault's discussionModel in its .memory-loop.json + to a name not in the registry. Open vault settings. Confirm the stored name is shown in + the dropdown with an "(unknown)" indicator. +
  6. +
+
+ + + + + diff --git a/daemon/src/__tests__/global-config.test.ts b/daemon/src/__tests__/global-config.test.ts new file mode 100644 index 00000000..25ac19d0 --- /dev/null +++ b/daemon/src/__tests__/global-config.test.ts @@ -0,0 +1,85 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + loadGlobalConfig, + getRegistry, + _resetRegistryForTesting, +} from "../global-config"; + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "global-config-test-")); + process.env.MEMORY_LOOP_CONFIG = join(tempDir, "config.json"); +}); + +afterEach(async () => { + delete process.env.MEMORY_LOOP_CONFIG; + _resetRegistryForTesting(); + await rm(tempDir, { recursive: true, force: true }); +}); + +describe("loadGlobalConfig", () => { + test("file not found → registry is empty, no throw", async () => { + process.env.MEMORY_LOOP_CONFIG = join(tempDir, "nonexistent.json"); + + await loadGlobalConfig(); + + expect(getRegistry()).toEqual({}); + }); + + test("malformed JSON → registry is empty, no throw", async () => { + await writeFile(join(tempDir, "config.json"), "{ this is not json }", "utf-8"); + + await loadGlobalConfig(); + + expect(getRegistry()).toEqual({}); + }); + + test("valid file with 3 entries → all 3 are loaded with correct shape", async () => { + const config = { + models: { + fast: { provider: "anthropic", modelId: "claude-haiku-3" }, + smart: { provider: "anthropic", modelId: "claude-sonnet-4" }, + local: { provider: "ollama", modelId: "llama3.2" }, + }, + }; + await writeFile(join(tempDir, "config.json"), JSON.stringify(config), "utf-8"); + + await loadGlobalConfig(); + + const reg = getRegistry(); + expect(Object.keys(reg)).toHaveLength(3); + expect(reg["fast"]).toEqual({ provider: "anthropic", modelId: "claude-haiku-3" }); + expect(reg["smart"]).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4" }); + expect(reg["local"]).toEqual({ provider: "ollama", modelId: "llama3.2" }); + }); + + test("one malformed entry (missing modelId) + one valid → valid retained, malformed skipped", async () => { + const config = { + models: { + broken: { provider: "anthropic" }, + good: { provider: "anthropic", modelId: "claude-sonnet-4" }, + }, + }; + await writeFile(join(tempDir, "config.json"), JSON.stringify(config), "utf-8"); + + await loadGlobalConfig(); + + const reg = getRegistry(); + expect(Object.keys(reg)).toHaveLength(1); + expect(reg["good"]).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4" }); + expect(reg["broken"]).toBeUndefined(); + }); + + test("empty models object → registry is empty", async () => { + const config = { models: {} }; + await writeFile(join(tempDir, "config.json"), JSON.stringify(config), "utf-8"); + + await loadGlobalConfig(); + + expect(getRegistry()).toEqual({}); + }); +}); diff --git a/daemon/src/__tests__/session-manager.test.ts b/daemon/src/__tests__/session-manager.test.ts index 07ce1c6e..f5d51061 100644 --- a/daemon/src/__tests__/session-manager.test.ts +++ b/daemon/src/__tests__/session-manager.test.ts @@ -1,11 +1,3 @@ -/** - * Session Manager Tests - * - * Tests session lifecycle, resume failure handling, and piSessionPath storage. - * Uses real filesystem (temp dirs) for vault config and session metadata. - * Uses mock pi-session factory via configurePiSessionForTesting. - */ - import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; @@ -22,15 +14,15 @@ import { _resetPiSessionForTesting, type PiSessionResult, } from "../pi-session-factory"; +import { + configureRegistryForTesting, + _resetRegistryForTesting, +} from "../global-config"; import type { SessionMetadata, VaultInfo } from "@memory-loop/shared"; import type { AgentSession } from "@earendil-works/pi-coding-agent"; let tempDir: string; -/** - * Minimal AgentSession mock that satisfies the interface shape. - * Only the fields actually accessed by session-manager are needed. - */ function makeMockAgentSession(): AgentSession { return { sessionFile: undefined, @@ -49,9 +41,6 @@ function makeMockAgentSession(): AgentSession { } as unknown as AgentSession; } -/** - * Builds a PiSessionResult for injection into the mock factory. - */ function makeMockPiSessionResult(jsonlPath: string | null = "/tmp/test.jsonl"): PiSessionResult { return { session: makeMockAgentSession(), @@ -65,13 +54,10 @@ beforeEach(async () => { afterEach(async () => { _resetPiSessionForTesting(); + _resetRegistryForTesting(); await rm(tempDir, { recursive: true, force: true }); }); -// ============================================================================= -// DISCUSSION_TOOLS constant -// ============================================================================= - describe("DISCUSSION_TOOLS", () => { test("contains the expected pi-agent built-in tool names", () => { expect(DISCUSSION_TOOLS).toContain("read"); @@ -80,7 +66,6 @@ describe("DISCUSSION_TOOLS", () => { }); test("does not contain legacy SDK tool names", () => { - // These were in DISCUSSION_MODE_OPTIONS.allowedTools but have no pi-agent equivalent expect(DISCUSSION_TOOLS).not.toContain("WebFetch"); expect(DISCUSSION_TOOLS).not.toContain("WebSearch"); expect(DISCUSSION_TOOLS).not.toContain("Task"); @@ -88,147 +73,127 @@ describe("DISCUSSION_TOOLS", () => { }); }); -// ============================================================================= -// createSession -// ============================================================================= - describe("createSession", () => { const mockVault: VaultInfo = { id: "test-vault", - path: "", // set in beforeEach + path: "", name: "Test Vault", contentRoot: "", } as VaultInfo; + let cleanupPiSession: (() => void) | undefined; + beforeEach(() => { mockVault.path = tempDir; mockVault.contentRoot = tempDir; }); + afterEach(() => { + cleanupPiSession?.(); + cleanupPiSession = undefined; + }); + + function mockPiSession(factory: Parameters[0]): void { + cleanupPiSession = configurePiSessionForTesting(factory); + } + test("stores piSessionPath in metadata when factory returns a path", async () => { - const mockResult = makeMockPiSessionResult("/tmp/pi-sessions/test.jsonl"); - const cleanup = configurePiSessionForTesting(async () => mockResult); - - try { - const result = await createSession(mockVault); - - const metadata = await loadSession(tempDir, result.sessionId); - expect(metadata).not.toBeNull(); - expect(metadata!.piSessionPath).toBe("/tmp/pi-sessions/test.jsonl"); - } finally { - cleanup(); - } + mockPiSession(async () => makeMockPiSessionResult("/tmp/pi-sessions/test.jsonl")); + + const result = await createSession(mockVault); + + const metadata = await loadSession(tempDir, result.sessionId); + expect(metadata).not.toBeNull(); + expect(metadata!.piSessionPath).toBe("/tmp/pi-sessions/test.jsonl"); }); test("stores undefined piSessionPath when factory returns null jsonlPath", async () => { - const mockResult = makeMockPiSessionResult(null); - const cleanup = configurePiSessionForTesting(async () => mockResult); - - try { - const result = await createSession(mockVault); - - const metadata = await loadSession(tempDir, result.sessionId); - expect(metadata).not.toBeNull(); - expect(metadata!.piSessionPath).toBeUndefined(); - } finally { - cleanup(); - } + mockPiSession(async () => makeMockPiSessionResult(null)); + + const result = await createSession(mockVault); + + const metadata = await loadSession(tempDir, result.sessionId); + expect(metadata).not.toBeNull(); + expect(metadata!.piSessionPath).toBeUndefined(); }); test("returns the session ID and piSession from the factory result", async () => { const fakeSession = makeMockAgentSession(); - const cleanup = configurePiSessionForTesting(async () => ({ - session: fakeSession, - jsonlPath: "/tmp/pi-sessions/test.jsonl", - })); + mockPiSession(async () => ({ session: fakeSession, jsonlPath: "/tmp/pi-sessions/test.jsonl" })); - try { - const result = await createSession(mockVault); + const result = await createSession(mockVault); - expect(result.sessionId).toBeTruthy(); - expect(result.piSession).toBe(fakeSession); - expect(result.previousMessages).toBeUndefined(); - } finally { - cleanup(); - } + expect(result.sessionId).toBeTruthy(); + expect(result.piSession).toBe(fakeSession); + expect(result.previousMessages).toBeUndefined(); }); test("session metadata is persisted on disk", async () => { - const cleanup = configurePiSessionForTesting(async () => makeMockPiSessionResult()); - - try { - const result = await createSession(mockVault); - - const loaded = await loadSession(tempDir, result.sessionId); - expect(loaded).not.toBeNull(); - expect(loaded!.id).toBe(result.sessionId); - expect(loaded!.vaultId).toBe(mockVault.id); - expect(loaded!.vaultPath).toBe(tempDir); - expect(loaded!.messages).toEqual([]); - } finally { - cleanup(); - } + mockPiSession(async () => makeMockPiSessionResult()); + + const result = await createSession(mockVault); + + const loaded = await loadSession(tempDir, result.sessionId); + expect(loaded).not.toBeNull(); + expect(loaded!.id).toBe(result.sessionId); + expect(loaded!.vaultId).toBe(mockVault.id); + expect(loaded!.vaultPath).toBe(tempDir); + expect(loaded!.messages).toEqual([]); }); test("uses vault config model when present", async () => { + configureRegistryForTesting({ + haiku: { provider: "anthropic", modelId: "claude-haiku-4-5" }, + }); await writeFile( join(tempDir, ".memory-loop.json"), JSON.stringify({ discussionModel: "haiku" }) ); let capturedOpts: { model?: { provider: string; modelId: string } } = {}; - const cleanup = configurePiSessionForTesting(async (opts) => { + mockPiSession(async (opts) => { capturedOpts = opts; return makeMockPiSessionResult(); }); - try { - await createSession(mockVault); - expect(capturedOpts.model).toEqual({ provider: "anthropic", modelId: "claude-haiku-4-5" }); - } finally { - cleanup(); - } + await createSession(mockVault); + expect(capturedOpts.model).toEqual({ provider: "anthropic", modelId: "claude-haiku-4-5" }); }); test("passes no model when vault config has no discussionModel", async () => { - // No .memory-loop.json — uses default "opus" which maps to anthropic/claude-opus-4-5 let capturedOpts: { model?: { provider: string; modelId: string } } = {}; - const cleanup = configurePiSessionForTesting(async (opts) => { + mockPiSession(async (opts) => { capturedOpts = opts; return makeMockPiSessionResult(); }); - try { - await createSession(mockVault); - // Default is "opus" - expect(capturedOpts.model).toEqual({ provider: "anthropic", modelId: "claude-opus-4-5" }); - } finally { - cleanup(); - } + await createSession(mockVault); + expect(capturedOpts.model).toBeUndefined(); }); test("wraps factory errors in SessionError with SDK_ERROR code", async () => { - const cleanup = configurePiSessionForTesting(async () => { + mockPiSession(async () => { throw new Error("rate_limit exceeded"); }); - try { - await createSession(mockVault); - expect(true).toBe(false); // should not reach here - } catch (err) { - expect(err).toBeInstanceOf(SessionError); - expect((err as SessionError).code).toBe("SDK_ERROR"); - } finally { - cleanup(); - } + const promise = createSession(mockVault); + await expect(promise).rejects.toBeInstanceOf(SessionError); + await expect(promise).rejects.toMatchObject({ code: "SDK_ERROR" }); }); }); -// ============================================================================= -// resumeSession failure detection -// ============================================================================= - describe("resumeSession failure detection", () => { + let cleanupPiSession: (() => void) | undefined; + + afterEach(() => { + cleanupPiSession?.(); + cleanupPiSession = undefined; + }); + + function mockPiSession(factory: Parameters[0]): void { + cleanupPiSession = configurePiSessionForTesting(factory); + } + async function createTestSession(sessionId: string, piSessionPath?: string): Promise { const sessionsDir = join(tempDir, ".memory-loop", "sessions"); await mkdir(sessionsDir, { recursive: true }); @@ -245,81 +210,56 @@ describe("resumeSession failure detection", () => { } test("throws RESUME_FAILED when piSessionPath is absent from metadata", async () => { - await createTestSession("sess-no-path"); // no piSessionPath - - try { - await resumeSession(tempDir, "sess-no-path"); - expect(true).toBe(false); // should not reach here - } catch (err) { - expect(err).toBeInstanceOf(SessionError); - const sessionErr = err as SessionError; - expect(sessionErr.code).toBe("RESUME_FAILED"); - expect(sessionErr.message).toContain("no pi-agent session path"); - } + await createTestSession("sess-no-path"); + + const promise = resumeSession(tempDir, "sess-no-path"); + await expect(promise).rejects.toBeInstanceOf(SessionError); + await expect(promise).rejects.toMatchObject({ + code: "RESUME_FAILED", + message: expect.stringContaining("no pi-agent session path"), + }); }); test("throws SESSION_NOT_FOUND when session metadata does not exist", async () => { - try { - await resumeSession(tempDir, "nonexistent-session"); - expect(true).toBe(false); - } catch (err) { - expect(err).toBeInstanceOf(SessionError); - expect((err as SessionError).code).toBe("SESSION_NOT_FOUND"); - } + const promise = resumeSession(tempDir, "nonexistent-session"); + await expect(promise).rejects.toBeInstanceOf(SessionError); + await expect(promise).rejects.toMatchObject({ code: "SESSION_NOT_FOUND" }); }); test("returns piSession and previousMessages on successful resume", async () => { await createTestSession("sess-with-path", "/tmp/pi-sessions/existing.jsonl"); const fakeSession = makeMockAgentSession(); - const cleanup = configurePiSessionForTesting(async () => ({ + mockPiSession(async () => ({ session: fakeSession, jsonlPath: "/tmp/pi-sessions/existing.jsonl", })); - try { - const result = await resumeSession(tempDir, "sess-with-path"); - expect(result.sessionId).toBe("sess-with-path"); - expect(result.piSession).toBe(fakeSession); - expect(result.previousMessages).toEqual([]); - } finally { - cleanup(); - } + const result = await resumeSession(tempDir, "sess-with-path"); + expect(result.sessionId).toBe("sess-with-path"); + expect(result.piSession).toBe(fakeSession); + expect(result.previousMessages).toEqual([]); }); test("wraps factory errors in SessionError with SDK_ERROR code", async () => { await createTestSession("sess-factory-error", "/tmp/pi-sessions/missing.jsonl"); - - const cleanup = configurePiSessionForTesting(async () => { + mockPiSession(async () => { throw new Error("ENOENT: file not found"); }); - try { - await resumeSession(tempDir, "sess-factory-error"); - expect(true).toBe(false); - } catch (err) { - expect(err).toBeInstanceOf(SessionError); - expect((err as SessionError).code).toBe("SDK_ERROR"); - } finally { - cleanup(); - } + const promise = resumeSession(tempDir, "sess-factory-error"); + await expect(promise).rejects.toBeInstanceOf(SessionError); + await expect(promise).rejects.toMatchObject({ code: "SDK_ERROR" }); }); test("SessionError thrown by factory propagates unchanged", async () => { await createTestSession("sess-session-error", "/tmp/pi-sessions/test.jsonl"); - - const cleanup = configurePiSessionForTesting(async () => { + mockPiSession(async () => { throw new SessionError("internal failure", "STORAGE_ERROR"); }); - try { - await resumeSession(tempDir, "sess-session-error"); - expect(true).toBe(false); - } catch (err) { - expect(err).toBeInstanceOf(SessionError); - expect((err as SessionError).code).toBe("STORAGE_ERROR"); - } finally { - cleanup(); - } + const promise = resumeSession(tempDir, "sess-session-error"); + await expect(promise).rejects.toBeInstanceOf(SessionError); + await expect(promise).rejects.toMatchObject({ code: "STORAGE_ERROR" }); }); }); diff --git a/daemon/src/global-config.ts b/daemon/src/global-config.ts new file mode 100644 index 00000000..5e83aa46 --- /dev/null +++ b/daemon/src/global-config.ts @@ -0,0 +1,89 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createLogger } from "@memory-loop/shared"; +import { getVaultsDir } from "./vault/vault-manager"; + +const log = createLogger("GlobalConfig"); + +export interface ModelEntry { + provider: string; + modelId: string; +} + +export type ModelRegistry = Record; + +let registry: ModelRegistry = {}; + +function getConfigFilePath(): string { + return ( + process.env.MEMORY_LOOP_CONFIG ?? + join(getVaultsDir(), "memory-loop-config.json") + ); +} + +function isModelEntry(value: unknown): value is ModelEntry { + if (typeof value !== "object" || value === null) return false; + const entry = value as Record; + return typeof entry.provider === "string" && typeof entry.modelId === "string"; +} + +function parseRegistry(parsed: unknown): ModelRegistry { + if (typeof parsed !== "object" || parsed === null || !("models" in parsed)) { + return {}; + } + const modelsRaw = (parsed as Record).models; + if (typeof modelsRaw !== "object" || modelsRaw === null) { + return {}; + } + + const result: ModelRegistry = {}; + for (const [name, entry] of Object.entries(modelsRaw)) { + if (isModelEntry(entry)) { + result[name] = entry; + } else { + log.warn(`Skipping invalid model entry "${name}" in global config`); + } + } + return result; +} + +export function getRegistry(): ModelRegistry { + return registry; +} + +export async function loadGlobalConfig(): Promise { + const path = getConfigFilePath(); + + let raw: string; + try { + raw = await readFile(path, "utf-8"); + } catch { + log.warn(`Global config not found at ${path}, model registry is empty`); + registry = {}; + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + log.warn(`Global config at ${path} is not valid JSON, model registry is empty`); + registry = {}; + return; + } + + registry = parseRegistry(parsed); + log.info(`Loaded ${Object.keys(registry).length} model(s) from global config`); +} + +export function configureRegistryForTesting(r: ModelRegistry): () => void { + const prev = registry; + registry = r; + return () => { + registry = prev; + }; +} + +export function _resetRegistryForTesting(): void { + registry = {}; +} diff --git a/daemon/src/index.ts b/daemon/src/index.ts index 368022bd..6730ab1d 100644 --- a/daemon/src/index.ts +++ b/daemon/src/index.ts @@ -1,14 +1,7 @@ -/** - * Memory Loop daemon entry point. - * - * Starts the HTTP server on a Unix socket (default) or localhost TCP port. - * Initializes SDK, vault cache, and background schedulers on boot. - * Handles SIGTERM/SIGINT for clean shutdown. - */ - import { createLogger } from "@memory-loop/shared"; import { startServer } from "./server"; import { initVaultCache } from "./vault"; +import { loadGlobalConfig } from "./global-config"; import { checkCwebpAvailability } from "./files/utils/image-converter"; import { startScheduler as startExtractionScheduler, @@ -26,25 +19,21 @@ const startTime = Date.now(); function getDefaultSocketPath(): string { const xdgRuntime = process.env.XDG_RUNTIME_DIR; - if (xdgRuntime) { - return `${xdgRuntime}/memory-loop.sock`; - } - return "/tmp/memory-loop.sock"; + return xdgRuntime ? `${xdgRuntime}/memory-loop.sock` : "/tmp/memory-loop.sock"; } -const socketPath = process.env.DAEMON_SOCKET ?? (process.env.DAEMON_PORT ? undefined : getDefaultSocketPath()); +const socketPath = + process.env.DAEMON_SOCKET ?? (process.env.DAEMON_PORT ? undefined : getDefaultSocketPath()); const port = process.env.DAEMON_PORT ? parseInt(process.env.DAEMON_PORT, 10) : undefined; -// Initialize vault cache before accepting requests to prevent -// early requests hitting an empty cache. +// Initialize caches before accepting requests so early requests don't hit empty state. await initVaultCache(); +await loadGlobalConfig(); -// Check cwebp binary availability (REQ-IMAGE-WEBP-15) -// Server continues regardless of result (REQ-IMAGE-WEBP-16) +// REQ-IMAGE-WEBP-15/16: probe binary but continue regardless of result. await checkCwebpAvailability(); -// Start background schedulers. Failures are logged but don't prevent startup. - +// Scheduler failures are logged but don't prevent startup. try { const started = await startExtractionScheduler(); if (started) { @@ -58,10 +47,7 @@ try { try { const hour = getDiscoveryHourFromEnv(); - await startCardDiscoveryScheduler({ - discoveryHour: hour, - catchUpOnStartup: true, - }); + await startCardDiscoveryScheduler({ discoveryHour: hour, catchUpOnStartup: true }); log.info(`Card discovery scheduler started (daily at ${hour}:00)`); } catch (error: unknown) { log.error("Failed to start card discovery scheduler", error); @@ -71,7 +57,7 @@ const server = startServer({ socketPath, port, startTime }); log.info("Memory Loop daemon started"); -function shutdown() { +function shutdown(): void { log.info("Shutting down..."); stopExtractionScheduler(); stopCardDiscoveryScheduler(); diff --git a/daemon/src/router.ts b/daemon/src/router.ts index b957fa7f..1ee07f7c 100644 --- a/daemon/src/router.ts +++ b/daemon/src/router.ts @@ -1,9 +1,3 @@ -/** - * Request router for the daemon API. - * - * Registers all routes on a Hono app instance. - */ - import type { Hono } from "hono"; import { healthHandler } from "./routes/health"; import { helpHandler } from "./routes/help"; @@ -80,6 +74,7 @@ import { import { assetHandler } from "./routes/assets"; import { setupHandler } from "./routes/setup"; import { inspirationHandler } from "./routes/inspiration"; +import { getModelsHandler } from "./routes/models"; import { chatSendHandler, chatStreamHandler, @@ -97,6 +92,7 @@ export function registerRoutes(app: Hono, startTime: number): void { // Health and help app.get("/health", (c) => healthHandler(c, startTime)); app.get("/help", (c) => helpHandler(c)); + app.get("/models", (c) => getModelsHandler(c)); // Vault routes (order matters: /vaults/help before /vaults/:id) app.get("/vaults", (c) => listVaultsHandler(c)); diff --git a/daemon/src/routes/__tests__/models.test.ts b/daemon/src/routes/__tests__/models.test.ts new file mode 100644 index 00000000..38eff8c1 --- /dev/null +++ b/daemon/src/routes/__tests__/models.test.ts @@ -0,0 +1,33 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { createApp } from "../../server"; +import { + configureRegistryForTesting, + _resetRegistryForTesting, +} from "../../global-config"; + +afterEach(() => { + _resetRegistryForTesting(); +}); + +describe("GET /models", () => { + test("returns empty array when registry is empty", async () => { + const app = createApp(Date.now()); + const response = await app.request("/models"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ models: [] }); + }); + + test("returns model entries from registry", async () => { + configureRegistryForTesting({ + opus: { provider: "anthropic", modelId: "claude-opus-4-7" }, + }); + const app = createApp(Date.now()); + const response = await app.request("/models"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + models: [{ name: "opus", provider: "anthropic", modelId: "claude-opus-4-7" }], + }); + }); +}); diff --git a/daemon/src/routes/models.ts b/daemon/src/routes/models.ts new file mode 100644 index 00000000..3dd8af51 --- /dev/null +++ b/daemon/src/routes/models.ts @@ -0,0 +1,11 @@ +import type { Context } from "hono"; +import { getRegistry } from "../global-config"; + +export function getModelsHandler(c: Context): Response { + const models = Object.entries(getRegistry()).map(([name, entry]) => ({ + name, + provider: entry.provider, + modelId: entry.modelId, + })); + return c.json({ models }); +} diff --git a/daemon/src/session-manager.ts b/daemon/src/session-manager.ts index 4b0e96c3..b334405c 100644 --- a/daemon/src/session-manager.ts +++ b/daemon/src/session-manager.ts @@ -1,9 +1,5 @@ -/** - * Session Manager - * - * Manages pi-agent session lifecycle: create, resume, and persistence. - * Sessions are stored in `.memory-loop/sessions/` as JSON files. - */ +// Pi-agent session lifecycle: create, resume, persistence. +// Sessions are stored in `.memory-loop/sessions/.json`. import { mkdir, readFile, writeFile, readdir, unlink } from "node:fs/promises"; import { join } from "node:path"; @@ -19,7 +15,6 @@ import { createLogger, formatDateForFilename, formatTimeForTimestamp, - resolveDiscussionModel, resolveRecentDiscussions, type ConversationMessage, type RecentDiscussionEntry, @@ -37,8 +32,8 @@ import { import { createVaultTransferTools } from "./vault-transfer"; import { loadVaultConfig } from "./vault/vault-config"; import { createPiSession } from "./pi-session-factory"; +import { getRegistry } from "./global-config"; -// Re-export types from shared for convenience export type { SessionMetadata, ConversationMessage } from "@memory-loop/shared"; const log = createLogger("Session"); @@ -46,55 +41,32 @@ const log = createLogger("Session"); /** * Built-in tool allowlist for Discussion mode. * - * Restricts to read-only operations and bash. Task/subagent tools are excluded - * because they inherit parent tools and could bypass permission checks. - * Web tools (WebFetch, WebSearch) are excluded because they require the - * pi-web-access extension which is not available to daemon sessions. + * Read-only operations plus bash. Task/subagent tools are excluded because they + * inherit parent tools and could bypass permission checks. Web tools (WebFetch, + * WebSearch) require the pi-web-access extension, which isn't wired in here. */ export const DISCUSSION_TOOLS = ["read", "grep", "bash"] as const; -/** - * Pi-agent model coordinates. Matches the `model` field accepted by createPiSession. - */ type PiAgentModel = { provider: string; modelId: string }; -/** - * Maps vault config discussion model names to pi-agent { provider, modelId } pairs. - * Used by createSession() and resumeSession() when building the createPiSession call. - */ -const DISCUSSION_MODEL_MAP: Record = { - opus: { provider: "anthropic", modelId: "claude-opus-4-5" }, - sonnet: { provider: "anthropic", modelId: "claude-sonnet-4-5" }, - haiku: { provider: "anthropic", modelId: "claude-haiku-4-5" }, -}; - -/** - * Relative path within vault for storing session metadata. - */ export const SESSIONS_DIR = ".memory-loop/sessions"; -/** - * Error thrown when session operations fail. - */ +type SessionErrorCode = + | "SESSION_NOT_FOUND" + | "SESSION_INVALID" + | "SDK_ERROR" + | "STORAGE_ERROR" + | "RESUME_FAILED"; + export class SessionError extends Error { - constructor( - message: string, - public readonly code: - | "SESSION_NOT_FOUND" - | "SESSION_INVALID" - | "SDK_ERROR" - | "STORAGE_ERROR" - | "RESUME_FAILED" - ) { + constructor(message: string, public readonly code: SessionErrorCode) { super(message); this.name = "SessionError"; } } -/** - * Substrings that identify known SDK error categories, paired with a user-friendly - * explanation. First match wins; order matters only if patterns overlap. - */ +// Substrings that identify known SDK error categories, paired with user-friendly +// explanations. First match wins; order matters only when patterns overlap. const SDK_ERROR_PATTERNS: ReadonlyArray = [ ["ENOENT", "Claude Code executable not found. Please ensure Claude Code is installed."], ["EACCES", "Permission denied. Unable to access required resources."], @@ -105,12 +77,6 @@ const SDK_ERROR_PATTERNS: ReadonlyArray { const sessionsDir = join(vaultPath, SESSIONS_DIR); - - // Ensure directory exists await mkdir(sessionsDir, { recursive: true }); - return sessionsDir; } /** - * Validates a session ID to prevent path traversal attacks. - * Session IDs must contain only alphanumeric characters, hyphens, and underscores. - * - * @param sessionId - The session ID to validate - * @returns true if valid - * @throws SessionError if invalid + * Throws SessionError if `sessionId` is unsafe to use as a filesystem path + * segment. Returns true on success so callers can use it as a guard. */ export function validateSessionId(sessionId: string): boolean { - // Session IDs from SDK are typically UUIDs or similar safe formats. - // Allow alphanumeric, hyphens, underscores, and periods (for UUIDs). + // Session IDs from the SDK are typically UUIDs. Permit alphanumeric, + // hyphen, underscore, and period (UUIDs use hyphens; periods seen in some IDs). // `/` and `\` cannot match this regex, so path traversal via separator is rejected here. const safePattern = /^[a-zA-Z0-9_.-]+$/; if (!sessionId || sessionId.length === 0) { throw new SessionError("Session ID cannot be empty", "SESSION_INVALID"); } - if (sessionId.length > 256) { throw new SessionError("Session ID is too long", "SESSION_INVALID"); } - if (!safePattern.test(sessionId)) { - throw new SessionError( - "Session ID contains invalid characters", - "SESSION_INVALID" - ); + throw new SessionError("Session ID contains invalid characters", "SESSION_INVALID"); } - // The regex permits `.`, so `..` survives the character check; reject explicitly. if (sessionId.includes("..")) { throw new SessionError( @@ -175,32 +121,19 @@ export function validateSessionId(sessionId: string): boolean { return true; } -/** - * Gets the absolute path to a session file. - * - * @param vaultPath - Absolute path to the vault root directory - * @param sessionId - The session ID - * @returns Absolute path to session JSON file - * @throws SessionError if session ID is invalid - */ -export async function getSessionFilePath(vaultPath: string, sessionId: string): Promise { +export async function getSessionFilePath( + vaultPath: string, + sessionId: string +): Promise { validateSessionId(sessionId); const sessionsDir = await getSessionsDir(vaultPath); return join(sessionsDir, `${sessionId}.json`); } -/** - * Saves session metadata to disk. - * Uses metadata.vaultPath to determine storage location. - * - * @param metadata - The session metadata to save - * @throws SessionError if storage fails - */ export async function saveSession(metadata: SessionMetadata): Promise { try { const filePath = await getSessionFilePath(metadata.vaultPath, metadata.id); - const content = JSON.stringify(metadata, null, 2); - await writeFile(filePath, content, "utf-8"); + await writeFile(filePath, JSON.stringify(metadata, null, 2), "utf-8"); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new SessionError( @@ -210,14 +143,6 @@ export async function saveSession(metadata: SessionMetadata): Promise { } } -/** - * Loads session metadata from disk. - * - * @param vaultPath - Absolute path to the vault root directory - * @param sessionId - The session ID to load - * @returns SessionMetadata or null if not found - * @throws SessionError if the file exists but is invalid - */ export async function loadSession( vaultPath: string, sessionId: string @@ -225,7 +150,6 @@ export async function loadSession( try { const filePath = await getSessionFilePath(vaultPath, sessionId); - // Check if file exists if (!(await fileExists(filePath))) { return null; } @@ -233,15 +157,11 @@ export async function loadSession( const content = await readFile(filePath, "utf-8"); const metadata = JSON.parse(content) as SessionMetadata; - // Validate required fields if (!metadata.id || !metadata.vaultId || !metadata.vaultPath) { - throw new SessionError( - `Session file is missing required fields`, - "SESSION_INVALID" - ); + throw new SessionError(`Session file is missing required fields`, "SESSION_INVALID"); } - // Migration: default messages to empty array for old session files + // Older session files predate the `messages` array. Default it so callers don't crash. metadata.messages = metadata.messages ?? []; return metadata; @@ -250,10 +170,7 @@ export async function loadSession( throw error; } if (error instanceof SyntaxError) { - throw new SessionError( - `Session file contains invalid JSON`, - "SESSION_INVALID" - ); + throw new SessionError(`Session file contains invalid JSON`, "SESSION_INVALID"); } const message = error instanceof Error ? error.message : String(error); throw new SessionError( @@ -263,21 +180,12 @@ export async function loadSession( } } -/** - * Deletes session metadata from disk. - * - * @param vaultPath - Absolute path to the vault root directory - * @param sessionId - The session ID to delete - * @returns true if deleted, false if not found - */ export async function deleteSession(vaultPath: string, sessionId: string): Promise { try { const filePath = await getSessionFilePath(vaultPath, sessionId); - if (!(await fileExists(filePath))) { return false; } - await unlink(filePath); return true; } catch { @@ -302,7 +210,6 @@ async function readSessionIds(sessionsDir: string): Promise { /** * Loads all sessions for a vault paired with their lastActive Date, sorted * most-recent first. Skips files that fail to load or have invalid timestamps. - * Returns an empty array if anything fails (e.g. sessions dir cannot be read). */ async function loadSessionsSortedByActivity( vaultPath: string @@ -320,7 +227,6 @@ async function loadSessionsSortedByActivity( if (Number.isNaN(lastActive.getTime())) continue; entries.push({ metadata, lastActive }); } catch { - // Skip corrupted session files log.debug(`Skipping corrupted session file: ${sessionId}.json`); } } @@ -332,12 +238,6 @@ async function loadSessionsSortedByActivity( } } -/** - * Lists all session IDs for a given vault. - * - * @param vaultPath - Absolute path to the vault root directory - * @returns Array of session IDs - */ export async function listSessionsByVault(vaultPath: string): Promise { try { const sessionsDir = await getSessionsDir(vaultPath); @@ -347,13 +247,14 @@ export async function listSessionsByVault(vaultPath: string): Promise } } -/** - * Gets recent discussion sessions for a vault, sorted by last activity. - * - * @param vaultPath - Absolute path to the vault root directory - * @param limit - Maximum number of discussions to return (default 5) - * @returns Array of RecentDiscussionEntry objects, sorted by most recent first - */ +function truncatePreview(text: string, maxLength: number): string { + const firstLine = text.split("\n")[0].trim(); + if (firstLine.length <= maxLength) { + return firstLine; + } + return firstLine.slice(0, maxLength - 1) + "…"; +} + export async function getRecentSessions( vaultPath: string, limit = 5 @@ -379,12 +280,6 @@ export async function getRecentSessions( }); } -/** - * Prunes old sessions for a vault, keeping only the most recent ones. - * - * @param vaultPath - Absolute path to the vault root directory - * @param keepCount - Number of sessions to keep (default: 5) - */ export async function pruneOldSessions( vaultPath: string, keepCount = 5 @@ -407,24 +302,6 @@ export async function pruneOldSessions( } } -/** - * Truncates a string to a maximum length, adding ellipsis if truncated. - */ -function truncatePreview(text: string, maxLength: number): string { - // Take first line only - const firstLine = text.split("\n")[0].trim(); - if (firstLine.length <= maxLength) { - return firstLine; - } - return firstLine.slice(0, maxLength - 1) + "…"; -} - -/** - * Updates the lastActiveAt timestamp for a session. - * - * @param vaultPath - Absolute path to the vault root directory - * @param sessionId - The session ID to update - */ export async function touchSession(vaultPath: string, sessionId: string): Promise { const metadata = await loadSession(vaultPath, sessionId); if (metadata) { @@ -433,28 +310,11 @@ export async function touchSession(vaultPath: string, sessionId: string): Promis } } -/** - * Gets the most recent session ID for a vault, if one exists. - * - * @param vaultPath - Absolute path to the vault root directory - * @returns The most recent session ID, or null if no session exists for this vault - */ -export async function getSessionForVault( - vaultPath: string -): Promise { +export async function getSessionForVault(vaultPath: string): Promise { const entries = await loadSessionsSortedByActivity(vaultPath); return entries[0]?.metadata.id ?? null; } -/** - * Appends a message to a session's conversation history. - * Also writes to the transcript file for Obsidian searchability. - * - * @param vaultPath - Absolute path to the vault root directory - * @param sessionId - The session ID - * @param message - The message to append - * @throws SessionError if session not found - */ export async function appendMessage( vaultPath: string, sessionId: string, @@ -464,16 +324,13 @@ export async function appendMessage( if (!metadata) { const filePath = await getSessionFilePath(vaultPath, sessionId); log.error(`Session file not found at: ${filePath}`); - throw new SessionError( - `Session "${sessionId}" not found`, - "SESSION_NOT_FOUND" - ); + throw new SessionError(`Session "${sessionId}" not found`, "SESSION_NOT_FOUND"); } metadata.messages.push(message); metadata.lastActiveAt = new Date().toISOString(); - // Initialize transcript on first user message + // Initialize transcript on the first user message of a session. if (message.role === "user" && !metadata.transcriptPath) { try { const vault = await getVaultById(metadata.vaultId); @@ -490,14 +347,13 @@ export async function appendMessage( log.warn(`Vault "${metadata.vaultId}" not found, skipping transcript`); } } catch (error) { - // Log error but don't fail the message append + // Transcript is best-effort; never block message append on it. log.warn("Failed to initialize transcript:", error); } } await saveSession(metadata); - // Append to transcript if path exists if (metadata.transcriptPath) { try { const timestamp = new Date(message.timestamp); @@ -507,7 +363,6 @@ export async function appendMessage( : formatAssistantMessage(message.content, message.toolInvocations, timestamp); await appendToTranscript(metadata.transcriptPath, formatted); } catch (error) { - // Log error but don't fail the message append log.warn("Failed to append to transcript:", error); } } @@ -515,32 +370,20 @@ export async function appendMessage( log.info(`Appended ${message.role} message to session ${sessionId.slice(0, 8)}...`); } -/** - * Result of a session creation or resume, wrapping the pi-agent session. - */ export interface SessionQueryResult { - /** The session ID (locally generated UUID) */ sessionId: string; - /** The live pi-agent session for streaming and control */ piSession: AgentSession; - /** Conversation history from prior turns (populated on resume) */ + /** Conversation history from prior turns (populated on resume). */ previousMessages?: ConversationMessage[]; } -/** - * Callback to request tool permission from the user. - * Returns true if the user allows the tool, false otherwise. - */ +/** Returns true to allow the tool, false to block it. */ export type ToolPermissionCallback = ( toolUseId: string, toolName: string, input: unknown ) => Promise; -/** - * Schema for a single question in an AskUserQuestion request. - * Matches the AskUserQuestionItemSchema from the shared protocol. - */ export interface AskUserQuestionItem { question: string; header: string; @@ -548,20 +391,12 @@ export interface AskUserQuestionItem { multiSelect: boolean; } -/** - * Callback to handle AskUserQuestion tool. - * Receives questions and returns a map of question text to selected answer(s). - */ +/** Receives questions and returns a map of question text to selected answer(s). */ export type AskUserQuestionCallback = ( toolUseId: string, questions: AskUserQuestionItem[] ) => Promise>; -/** - * Builds a pi-agent ExtensionFactory that gates every tool call through - * the provided ToolPermissionCallback. When the user denies permission, - * the factory returns a block result so the tool is not executed. - */ function createPermissionExtension(callback: ToolPermissionCallback): ExtensionFactory { return (pi) => { pi.on("tool_call", async (event) => { @@ -570,7 +405,6 @@ function createPermissionExtension(callback: ToolPermissionCallback): ExtensionF if (!allowed) { return { block: true, reason: `User denied permission for ${event.toolName}` }; } - // Returning undefined allows the tool to proceed return undefined; } catch (err) { log.error(`Permission callback threw for ${event.toolName} — blocking tool call`, err); @@ -580,10 +414,6 @@ function createPermissionExtension(callback: ToolPermissionCallback): ExtensionF }; } -/** - * Builds the AskUserQuestion custom tool definition, wiring the provided - * callback so the agent can ask the user structured questions during a turn. - */ function createAskUserQuestionTool(callback: AskUserQuestionCallback): ToolDefinition { return defineTool({ name: "AskUserQuestion", @@ -601,7 +431,9 @@ function createAskUserQuestionTool(callback: AskUserQuestionCallback): ToolDefin }), { description: "Available options (2-4)" } ), - multiSelect: Type.Boolean({ description: "Whether multiple options can be selected" }), + multiSelect: Type.Boolean({ + description: "Whether multiple options can be selected", + }), }), { description: "List of questions to ask the user" } ), @@ -616,27 +448,23 @@ function createAskUserQuestionTool(callback: AskUserQuestionCallback): ToolDefin }); } -/** - * Maps a vault config discussion model name to a pi-agent { provider, modelId } pair. - * Returns undefined if the model name is not recognised (triggers fallback in the factory). - */ function resolveModelForPiAgent( vaultPath: string, config: Awaited> ): PiAgentModel | undefined { - const modelName = resolveDiscussionModel(config); - const mapped = DISCUSSION_MODEL_MAP[modelName]; - if (!mapped) { - log.warn(`Unknown discussion model "${modelName}" for vault at ${vaultPath}, using pi-agent fallback`); + const modelName = config.discussionModel; + if (!modelName) return undefined; + + const entry = getRegistry()[modelName]; + if (!entry) { + log.warn( + `Unknown discussion model "${modelName}" for vault at ${vaultPath}, using pi-agent fallback` + ); + return undefined; } - return mapped; + return entry; } -/** - * Builds the discussion extension factories and custom tools shared by - * createSession() and resumeSession(). Both flows wire the same callbacks - * (tool permission gating, AskUserQuestion) and the same vault-transfer tools. - */ function buildSessionExtensions( requestToolPermission: ToolPermissionCallback | undefined, askUserQuestion: AskUserQuestionCallback | undefined @@ -657,13 +485,10 @@ function buildSessionExtensions( } /** - * Opens a pi-agent session for the given vault using either a fresh `create` - * or a `resume` SessionManager. Loads vault config, resolves the discussion - * model, wires extensions, and logs the chosen tooling. Used by both - * createSession() and resumeSession() so the setup stays in one place. - * - * Returns the loaded config alongside the pi-session result so callers don't - * need a second loadVaultConfig() call (e.g. createSession uses it for pruning). + * Opens a pi-agent session for the given vault. Used by createSession() (with a + * fresh SessionManager) and resumeSession() (with an open one) so the wiring stays + * in one place. Returns the loaded vault config alongside the result so callers + * don't need a second loadVaultConfig() call. */ async function openPiSessionForVault( vaultPath: string, @@ -701,9 +526,8 @@ async function openPiSessionForVault( } /** - * Wraps non-SessionError exceptions in a SessionError(SDK_ERROR). Logs context - * via the supplied `operation` label. Re-throws SessionError instances as-is so - * their original codes (e.g. RESUME_FAILED) survive. + * Wraps non-SessionError exceptions in a SessionError(SDK_ERROR). Re-throws + * existing SessionError instances unchanged so codes like RESUME_FAILED survive. */ function wrapSdkFailure(operation: string, error: unknown): never { log.error(`Failed to ${operation}`, error); @@ -713,14 +537,6 @@ function wrapSdkFailure(operation: string, error: unknown): never { throw new SessionError(mapSdkError(error), "SDK_ERROR"); } -/** - * Creates a new pi-agent session for a vault. - * - * @param vault - The vault to create a session for - * @param requestToolPermission - Optional callback to request tool permission from user - * @param askUserQuestion - Optional callback to handle AskUserQuestion tool - * @returns SessionQueryResult with session ID and pi-agent session - */ export async function createSession( vault: VaultInfo, requestToolPermission?: ToolPermissionCallback, @@ -737,10 +553,7 @@ export async function createSession( askUserQuestion ); - // Generate a locally-owned UUID — no longer extracted from the first event. const sessionId = crypto.randomUUID(); - - // Persist session metadata, including the JSONL path for future resume. const now = new Date().toISOString(); const metadata: SessionMetadata = { id: sessionId, @@ -752,9 +565,11 @@ export async function createSession( piSessionPath: result.jsonlPath ?? undefined, }; await saveSession(metadata); - log.info(`Session created: ${sessionId}, piSessionPath=${result.jsonlPath ?? "(none)"}`); + log.info( + `Session created: ${sessionId}, piSessionPath=${result.jsonlPath ?? "(none)"}` + ); - // Prune old sessions in background (non-blocking, errors logged internally) + // Prune in background; errors are logged inside pruneOldSessions. void pruneOldSessions(vault.path, resolveRecentDiscussions(config)); return { @@ -766,15 +581,6 @@ export async function createSession( } } -/** - * Resumes an existing pi-agent session. - * - * @param vaultPath - Absolute path to the vault root directory - * @param sessionId - The session ID to resume - * @param requestToolPermission - Optional callback to request tool permission from user - * @param askUserQuestion - Optional callback to handle AskUserQuestion tool - * @returns SessionQueryResult with session ID and pi-agent session - */ export async function resumeSession( vaultPath: string, sessionId: string, @@ -783,15 +589,11 @@ export async function resumeSession( ): Promise { log.info(`Resuming session: ${sessionId}`); - // Load existing session metadata const metadata = await loadSession(vaultPath, sessionId); if (!metadata) { log.warn(`Session not found: ${sessionId}`); - throw new SessionError( - `Session "${sessionId}" not found`, - "SESSION_NOT_FOUND" - ); + throw new SessionError(`Session "${sessionId}" not found`, "SESSION_NOT_FOUND"); } log.info(`Session metadata loaded: vault=${metadata.vaultId}`); @@ -813,7 +615,6 @@ export async function resumeSession( askUserQuestion ); - // Update last-active timestamp metadata.lastActiveAt = new Date().toISOString(); await saveSession(metadata); diff --git a/daemon/src/vault/__tests__/vault-config.test.ts b/daemon/src/vault/__tests__/vault-config.test.ts index 5a561178..343dd946 100644 --- a/daemon/src/vault/__tests__/vault-config.test.ts +++ b/daemon/src/vault/__tests__/vault-config.test.ts @@ -1,11 +1,6 @@ -/** - * Vault Configuration Tests - * - * Tests for per-vault configuration loading and path resolution. - */ - import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { mkdir, rm, writeFile, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { @@ -24,10 +19,8 @@ import { DEFAULT_PROMPTS_PER_GENERATION, DEFAULT_MAX_POOL_SIZE, DEFAULT_QUOTES_PER_WEEK, - DEFAULT_DISCUSSION_MODEL, DEFAULT_CARDS_ENABLED, DEFAULT_VI_MODE, - VALID_DISCUSSION_MODELS, resolveMetadataPath, resolveGoalsPath, resolveContextualPromptsPath, @@ -39,7 +32,6 @@ import { resolveQuotesPerWeek, resolveBadges, resolvePinnedAssets, - resolveDiscussionModel, resolveCardsEnabled, resolveViMode, slashCommandsEqual, @@ -47,7 +39,6 @@ import { import { resolveContentRoot } from "@memory-loop/shared/server"; import type { VaultConfig, SlashCommand, EditableVaultConfig } from "@memory-loop/shared"; -// Test helpers async function writeConfig(dir: string, data: unknown): Promise { await writeFile(join(dir, CONFIG_FILE_NAME), JSON.stringify(data)); } @@ -69,11 +60,7 @@ describe("vault-config", () => { }); afterEach(async () => { - try { - await rm(testDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } + await rm(testDir, { recursive: true, force: true }).catch(() => {}); }); describe("exported constants", () => { @@ -85,10 +72,8 @@ describe("vault-config", () => { expect(DEFAULT_PROMPTS_PER_GENERATION).toBe(5); expect(DEFAULT_MAX_POOL_SIZE).toBe(50); expect(DEFAULT_QUOTES_PER_WEEK).toBe(1); - expect(DEFAULT_DISCUSSION_MODEL).toBe("opus"); expect(DEFAULT_CARDS_ENABLED).toBe(true); expect(DEFAULT_VI_MODE).toBe(false); - expect(VALID_DISCUSSION_MODELS).toEqual(["opus", "sonnet", "haiku"]); }); }); @@ -242,8 +227,8 @@ describe("vault-config", () => { }); describe("discussionModel", () => { - test.each(["opus", "sonnet", "haiku"] as const)( - "loads valid discussionModel %s", + test.each(["opus", "sonnet", "haiku", "custom-model", "my-provider/my-model"])( + "loads discussionModel %s", async (model) => { await writeConfig(testDir, { discussionModel: model }); @@ -252,13 +237,6 @@ describe("vault-config", () => { } ); - test("ignores invalid discussionModel value", async () => { - await writeConfig(testDir, { discussionModel: "invalid-model" }); - - const config = await loadVaultConfig(testDir); - expect(config.discussionModel).toBeUndefined(); - }); - test("ignores non-string discussionModel value", async () => { await writeConfig(testDir, { discussionModel: 123 }); @@ -327,9 +305,9 @@ describe("vault-config", () => { badges: [ { text: "Valid", color: "blue" }, { text: "Invalid Color", color: "pink" }, - { color: "red" }, // missing text - { text: "", color: "green" }, // empty text - { text: "No Color" }, // missing color + { color: "red" }, + { text: "", color: "green" }, + { text: "No Color" }, null, "string", 42, @@ -497,17 +475,6 @@ describe("vault-config", () => { }); }); - describe("resolveDiscussionModel", () => { - test("returns default when not configured", () => { - expect(resolveDiscussionModel({})).toBe(DEFAULT_DISCUSSION_MODEL); - expect(resolveDiscussionModel({ discussionModel: undefined })).toBe(DEFAULT_DISCUSSION_MODEL); - }); - - test.each(["opus", "sonnet", "haiku"] as const)("returns configured model %s", (model) => { - expect(resolveDiscussionModel({ discussionModel: model })).toBe(model); - }); - }); - describe("boolean resolvers", () => { test("resolveCardsEnabled returns default or configured value", () => { expect(resolveCardsEnabled({})).toBe(true); @@ -865,30 +832,14 @@ describe("vault-config", () => { const result = await saveVaultConfig(testDir, {}); expect(result).toEqual({ success: true }); - - const configPath = join(testDir, CONFIG_FILE_NAME); - let fileExists = true; - try { - await readFile(configPath, "utf-8"); - } catch { - fileExists = false; - } - expect(fileExists).toBe(false); + expect(existsSync(join(testDir, CONFIG_FILE_NAME))).toBe(false); }); test("does NOT create file if only empty badges array provided", async () => { const result = await saveVaultConfig(testDir, { badges: [] }); expect(result).toEqual({ success: true }); - - const configPath = join(testDir, CONFIG_FILE_NAME); - let fileExists = true; - try { - await readFile(configPath, "utf-8"); - } catch { - fileExists = false; - } - expect(fileExists).toBe(false); + expect(existsSync(join(testDir, CONFIG_FILE_NAME))).toBe(false); }); test("merges only editable fields over existing config", async () => { diff --git a/daemon/src/vault/vault-config.ts b/daemon/src/vault/vault-config.ts index 29aad8aa..3ae12c2c 100644 --- a/daemon/src/vault/vault-config.ts +++ b/daemon/src/vault/vault-config.ts @@ -1,27 +1,64 @@ -/** - * Vault Configuration I/O (Daemon) - * - * Filesystem operations for loading and saving vault configuration. - * Types and resolver functions are in @memory-loop/shared. - */ - import { readFile, writeFile, mkdir } from "node:fs/promises"; import { join, dirname } from "node:path"; -import type { SlashCommand, Badge, BadgeColor, EditableVaultConfig, VaultConfig, SaveConfigResult } from "@memory-loop/shared"; +import type { + SlashCommand, + Badge, + BadgeColor, + EditableVaultConfig, + VaultConfig, + SaveConfigResult, +} from "@memory-loop/shared"; import { createLogger, CONFIG_FILE_NAME, SLASH_COMMANDS_FILE, - VALID_DISCUSSION_MODELS, VALID_BADGE_COLORS, } from "@memory-loop/shared"; import { fileExists } from "@memory-loop/shared/server"; const log = createLogger("VaultConfig"); +export type { SaveConfigResult }; + /** - * Loads vault configuration from .memory-loop.json if it exists. + * Reads a JSON file and returns its parsed object form. Returns an empty object + * if the file is missing, unreadable, or doesn't parse to a plain object. */ +async function readJsonObject(path: string): Promise> { + if (!(await fileExists(path))) { + return {}; + } + try { + const content = await readFile(path, "utf-8"); + const parsed = JSON.parse(content) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Fall through to empty object below. + } + return {}; +} + +async function writeJsonFile(path: string, data: unknown): Promise { + await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf-8"); +} + +function isPositiveInt(value: unknown): value is number { + return typeof value === "number" && value > 0; +} + +function isBadge(value: unknown): value is Badge { + if (typeof value !== "object" || value === null) return false; + const badge = value as Record; + return ( + typeof badge.text === "string" && + badge.text !== "" && + typeof badge.color === "string" && + VALID_BADGE_COLORS.includes(badge.color as BadgeColor) + ); +} + export async function loadVaultConfig(vaultPath: string): Promise { const configPath = join(vaultPath, CONFIG_FILE_NAME); @@ -29,83 +66,72 @@ export async function loadVaultConfig(vaultPath: string): Promise { return {}; } + let obj: Record; try { const content = await readFile(configPath, "utf-8"); const parsed = JSON.parse(content) as unknown; - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { log.warn(`Invalid config format in ${configPath}: expected object`); return {}; } + obj = parsed as Record; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.warn(`Failed to load config from ${configPath}: ${message}`); + return {}; + } - const config: VaultConfig = {}; - const obj = parsed as Record; - - if (typeof obj.title === "string") config.title = obj.title; - if (typeof obj.subtitle === "string") config.subtitle = obj.subtitle; - if (typeof obj.contentRoot === "string") config.contentRoot = obj.contentRoot; - if (typeof obj.inboxPath === "string") config.inboxPath = obj.inboxPath; - if (typeof obj.metadataPath === "string") config.metadataPath = obj.metadataPath; - if (typeof obj.projectPath === "string") config.projectPath = obj.projectPath; - if (typeof obj.areaPath === "string") config.areaPath = obj.areaPath; - if (typeof obj.attachmentPath === "string") config.attachmentPath = obj.attachmentPath; - - if (typeof obj.promptsPerGeneration === "number" && obj.promptsPerGeneration > 0) { - config.promptsPerGeneration = Math.floor(obj.promptsPerGeneration); - } - if (typeof obj.maxPoolSize === "number" && obj.maxPoolSize > 0) { - config.maxPoolSize = Math.floor(obj.maxPoolSize); - } - if (typeof obj.quotesPerWeek === "number" && obj.quotesPerWeek > 0) { - config.quotesPerWeek = Math.floor(obj.quotesPerWeek); - } - if (typeof obj.recentCaptures === "number" && obj.recentCaptures > 0) { - config.recentCaptures = Math.floor(obj.recentCaptures); - } - if (typeof obj.recentDiscussions === "number" && obj.recentDiscussions > 0) { - config.recentDiscussions = Math.floor(obj.recentDiscussions); - } - - if ( - typeof obj.discussionModel === "string" && - VALID_DISCUSSION_MODELS.includes(obj.discussionModel as typeof VALID_DISCUSSION_MODELS[number]) - ) { - config.discussionModel = obj.discussionModel; + const config: VaultConfig = {}; + + const stringFields = [ + "title", + "subtitle", + "contentRoot", + "inboxPath", + "metadataPath", + "projectPath", + "areaPath", + "attachmentPath", + "discussionModel", + ] as const; + for (const field of stringFields) { + if (typeof obj[field] === "string") { + (config as Record)[field] = obj[field]; } + } - if (typeof obj.order === "number" && Number.isFinite(obj.order)) { - config.order = obj.order; + const positiveIntFields = [ + "promptsPerGeneration", + "maxPoolSize", + "quotesPerWeek", + "recentCaptures", + "recentDiscussions", + ] as const; + for (const field of positiveIntFields) { + const value = obj[field]; + if (isPositiveInt(value)) { + (config as Record)[field] = Math.floor(value); } - if (typeof obj.cardsEnabled === "boolean") config.cardsEnabled = obj.cardsEnabled; - if (typeof obj.viMode === "boolean") config.viMode = obj.viMode; + } - if (Array.isArray(obj.badges)) { - config.badges = obj.badges.filter( - (badge): badge is Badge => - typeof badge === "object" && - badge !== null && - typeof (badge as Record).text === "string" && - (badge as Record).text !== "" && - typeof (badge as Record).color === "string" && - VALID_BADGE_COLORS.includes((badge as Record).color as BadgeColor) - ); - } + if (typeof obj.order === "number" && Number.isFinite(obj.order)) { + config.order = obj.order; + } + if (typeof obj.cardsEnabled === "boolean") config.cardsEnabled = obj.cardsEnabled; + if (typeof obj.viMode === "boolean") config.viMode = obj.viMode; - if (Array.isArray(obj.pinnedAssets)) { - config.pinnedAssets = obj.pinnedAssets.filter( - (path): path is string => typeof path === "string" && path.length > 0 - ); - } + if (Array.isArray(obj.badges)) { + config.badges = obj.badges.filter(isBadge); + } - return config; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log.warn(`Failed to load config from ${configPath}: ${message}`); - return {}; + if (Array.isArray(obj.pinnedAssets)) { + config.pinnedAssets = obj.pinnedAssets.filter( + (path): path is string => typeof path === "string" && path.length > 0 + ); } -} -export type { SaveConfigResult }; + return config; +} function isAllDefaults(config: EditableVaultConfig): boolean { return ( @@ -138,37 +164,19 @@ export async function saveVaultConfig( return { success: true }; } - let existingConfig: Record = {}; + const mergedConfig: Record = configExists + ? await readJsonObject(configPath) + : {}; - if (configExists) { - try { - const content = await readFile(configPath, "utf-8"); - const parsed = JSON.parse(content) as unknown; - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - existingConfig = parsed as Record; - } - } catch { - log.warn(`Could not parse existing config at ${configPath}, will overwrite`); + // Copy every defined field from the editable config onto the merged config. + // Undefined fields are skipped so existing values survive partial updates. + for (const [key, value] of Object.entries(editableConfig)) { + if (value !== undefined) { + mergedConfig[key] = value; } } - const mergedConfig: Record = { ...existingConfig }; - - if (editableConfig.title !== undefined) mergedConfig.title = editableConfig.title; - if (editableConfig.subtitle !== undefined) mergedConfig.subtitle = editableConfig.subtitle; - if (editableConfig.discussionModel !== undefined) mergedConfig.discussionModel = editableConfig.discussionModel; - if (editableConfig.promptsPerGeneration !== undefined) mergedConfig.promptsPerGeneration = editableConfig.promptsPerGeneration; - if (editableConfig.maxPoolSize !== undefined) mergedConfig.maxPoolSize = editableConfig.maxPoolSize; - if (editableConfig.quotesPerWeek !== undefined) mergedConfig.quotesPerWeek = editableConfig.quotesPerWeek; - if (editableConfig.recentCaptures !== undefined) mergedConfig.recentCaptures = editableConfig.recentCaptures; - if (editableConfig.recentDiscussions !== undefined) mergedConfig.recentDiscussions = editableConfig.recentDiscussions; - if (editableConfig.badges !== undefined) mergedConfig.badges = editableConfig.badges; - if (editableConfig.order !== undefined) mergedConfig.order = editableConfig.order; - if (editableConfig.cardsEnabled !== undefined) mergedConfig.cardsEnabled = editableConfig.cardsEnabled; - if (editableConfig.viMode !== undefined) mergedConfig.viMode = editableConfig.viMode; - - await writeFile(configPath, JSON.stringify(mergedConfig, null, 2) + "\n", "utf-8"); - + await writeJsonFile(configPath, mergedConfig); log.info(`Saved vault config to ${configPath}`); return { success: true }; } catch (error) { @@ -178,29 +186,11 @@ export async function saveVaultConfig( } } -export async function savePinnedAssets( - vaultPath: string, - paths: string[] -): Promise { +export async function savePinnedAssets(vaultPath: string, paths: string[]): Promise { const configPath = join(vaultPath, CONFIG_FILE_NAME); - - let existingConfig: Record = {}; - - if (await fileExists(configPath)) { - try { - const content = await readFile(configPath, "utf-8"); - const parsed = JSON.parse(content) as unknown; - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - existingConfig = parsed as Record; - } - } catch { - // If we can't read existing config, start fresh - } - } - + const existingConfig = await readJsonObject(configPath); existingConfig.pinnedAssets = paths; - - await writeFile(configPath, JSON.stringify(existingConfig, null, 2) + "\n", "utf-8"); + await writeJsonFile(configPath, existingConfig); log.info(`Saved ${paths.length} pinned assets to ${configPath}`); } @@ -249,9 +239,7 @@ export async function saveSlashCommands( commands: SlashCommand[] ): Promise { const cachePath = join(vaultPath, SLASH_COMMANDS_FILE); - await mkdir(dirname(cachePath), { recursive: true }); - - await writeFile(cachePath, JSON.stringify(commands, null, 2) + "\n", "utf-8"); + await writeJsonFile(cachePath, commands); log.info(`Cached ${commands.length} slash commands to ${cachePath}`); } diff --git a/daemon/src/vault/vault-manager.ts b/daemon/src/vault/vault-manager.ts index a20b9782..f3302136 100644 --- a/daemon/src/vault/vault-manager.ts +++ b/daemon/src/vault/vault-manager.ts @@ -1,9 +1,4 @@ -/** - * Vault Manager (Daemon) - * - * Vault discovery, creation, and filesystem operations. - * This is the authoritative implementation per REQ-DAB-1. - */ +// Vault discovery, creation, and filesystem operations. Authoritative per REQ-DAB-1. import { readdir, readFile, mkdir, writeFile } from "node:fs/promises"; import { join, dirname } from "node:path"; @@ -23,7 +18,6 @@ import { resolveQuotesPerWeek, resolveRecentCaptures, resolveRecentDiscussions, - resolveDiscussionModel, resolveBadges, resolveOrder, resolveCardsEnabled, @@ -43,10 +37,6 @@ export class VaultsDirError extends Error { export const DEFAULT_VAULTS_DIR_NAME = "vaults"; -/** - * Gets the daemon's root directory. - * Uses DAEMON_ROOT env var or falls back to the daemon package's parent directory. - */ function getDaemonRoot(): string { if (process.env.DAEMON_ROOT) { return process.env.DAEMON_ROOT; @@ -126,6 +116,7 @@ export async function parseVault( const config = await loadVaultConfig(vaultPath); const contentRoot = resolveContentRoot(vaultPath, config); + // Resolution order: vault config > CLAUDE.md extraction > directory name. let name = dirName; let subtitle: string | undefined; try { @@ -136,7 +127,7 @@ export async function parseVault( subtitle = extracted.subtitle; } } catch { - // Failed to read CLAUDE.md, use directory name + // Fall through; use directory name. } if (config.title) { @@ -166,7 +157,7 @@ export async function parseVault( goalsPath, attachmentPath, setupComplete, - discussionModel: resolveDiscussionModel(config), + discussionModel: config.discussionModel, promptsPerGeneration: resolvePromptsPerGeneration(config), maxPoolSize: resolveMaxPoolSize(config), quotesPerWeek: resolveQuotesPerWeek(config), diff --git a/nextjs/app/api/models/route.ts b/nextjs/app/api/models/route.ts new file mode 100644 index 00000000..c6f6ad01 --- /dev/null +++ b/nextjs/app/api/models/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { daemonFetch } from "@/lib/daemon/fetch"; + +export async function GET() { + const res = await daemonFetch("/models"); + return NextResponse.json(await res.json(), { status: res.status }); +} diff --git a/nextjs/components/vault/ConfigEditorDialog.tsx b/nextjs/components/vault/ConfigEditorDialog.tsx index 5c870281..f6da6cfb 100644 --- a/nextjs/components/vault/ConfigEditorDialog.tsx +++ b/nextjs/components/vault/ConfigEditorDialog.tsx @@ -1,9 +1,6 @@ /** - * ConfigEditorDialog Component - * - * Portal-based modal dialog for editing vault configuration settings. - * Displays a form with all editable config fields and handles change detection. - * Uses ConfirmDialog for unsaved changes confirmation. + * Portal-based modal dialog for editing vault configuration. Uses + * ConfirmDialog for unsaved-changes confirmation. */ import { @@ -16,11 +13,10 @@ import { } from "react"; import { createPortal } from "react-dom"; import { ConfirmDialog } from "../shared/ConfirmDialog"; +import type { ModelEntry } from "@/lib/daemon/models"; import "./ConfigEditorDialog.css"; -/** - * Valid badge colors (matches BadgeColorSchema from protocol.ts) - */ +// Matches BadgeColorSchema from packages/shared protocol. export type BadgeColor = | "black" | "purple" @@ -31,31 +27,25 @@ export type BadgeColor = | "green" | "yellow"; -/** - * Badge configuration - */ export interface Badge { text: string; color: BadgeColor; } -/** - * Editable vault configuration fields. - * This represents the subset of vault config that users can modify. - */ +// Subset of vault config users can modify. Bounds enforced by sliders/inputs. export interface EditableVaultConfig { title?: string; subtitle?: string; - discussionModel?: "opus" | "sonnet" | "haiku"; + discussionModel?: string; promptsPerGeneration?: number; // 1-20 maxPoolSize?: number; // 10-200 quotesPerWeek?: number; // 0-7 recentCaptures?: number; // 1-20 recentDiscussions?: number; // 1-20 badges?: Badge[]; // max 5 - order?: number; // display order on vault selection screen - cardsEnabled?: boolean; // whether spaced repetition card discovery is enabled - viMode?: boolean; // whether vi-style editing is enabled in Pair Writing + order?: number; + cardsEnabled?: boolean; + viMode?: boolean; } export interface ConfigEditorDialogProps { @@ -63,68 +53,41 @@ export interface ConfigEditorDialogProps { initialConfig: EditableVaultConfig; onSave: (config: EditableVaultConfig) => void | Promise; onCancel: () => void; - /** Show loading indicator during save (TASK-010) */ isSaving?: boolean; - /** Show inline error message if save failed (TASK-010) */ saveError?: string | null; } -/** - * Deep comparison of two config objects. - * Returns true if they differ. - */ function hasConfigChanged( initial: EditableVaultConfig, current: EditableVaultConfig ): boolean { - // Compare primitive fields - if (initial.title !== current.title) return true; - if (initial.subtitle !== current.subtitle) return true; - if (initial.discussionModel !== current.discussionModel) return true; - if (initial.promptsPerGeneration !== current.promptsPerGeneration) return true; - if (initial.maxPoolSize !== current.maxPoolSize) return true; - if (initial.quotesPerWeek !== current.quotesPerWeek) return true; - if (initial.recentCaptures !== current.recentCaptures) return true; - if (initial.recentDiscussions !== current.recentDiscussions) return true; - if (initial.order !== current.order) return true; - if (initial.cardsEnabled !== current.cardsEnabled) return true; - if (initial.viMode !== current.viMode) return true; - - // Compare badges array + if ( + initial.title !== current.title || + initial.subtitle !== current.subtitle || + initial.discussionModel !== current.discussionModel || + initial.promptsPerGeneration !== current.promptsPerGeneration || + initial.maxPoolSize !== current.maxPoolSize || + initial.quotesPerWeek !== current.quotesPerWeek || + initial.recentCaptures !== current.recentCaptures || + initial.recentDiscussions !== current.recentDiscussions || + initial.order !== current.order || + initial.cardsEnabled !== current.cardsEnabled || + initial.viMode !== current.viMode + ) { + return true; + } + const initialBadges = initial.badges ?? []; const currentBadges = current.badges ?? []; - if (initialBadges.length !== currentBadges.length) return true; - for (let i = 0; i < initialBadges.length; i++) { - if ( - initialBadges[i].text !== currentBadges[i].text || - initialBadges[i].color !== currentBadges[i].color - ) { - return true; - } - } - - return false; + return initialBadges.some( + (badge, i) => + badge.text !== currentBadges[i].text || + badge.color !== currentBadges[i].color + ); } -/** - * Predefined badge colors available for selection - */ -const BADGE_COLORS: BadgeColor[] = [ - "black", - "purple", - "red", - "cyan", - "orange", - "blue", - "green", - "yellow", -]; - -/** - * CSS color values for badge backgrounds - */ const BADGE_COLOR_VALUES: Record = { black: "var(--color-badge-black, #333)", purple: "var(--color-badge-purple, #9b59b6)", @@ -136,33 +99,24 @@ const BADGE_COLOR_VALUES: Record = { yellow: "var(--color-badge-yellow, #f1c40f)", }; -/** - * Maximum character length for badge text (REQ-F-20) - */ +const BADGE_COLORS = Object.keys(BADGE_COLOR_VALUES) as BadgeColor[]; + +// REQ-F-20: 20-character cap on badge text. const MAX_BADGE_TEXT_LENGTH = 20; -/** - * Props for the BadgeEditor subcomponent - */ interface BadgeEditorProps { badges: Badge[]; onChange: (badges: Badge[]) => void; + // REQ-F-21: at most 5 badges. maxBadges?: number; } -/** - * BadgeEditor Component - * - * Allows users to add, remove, and customize badge chips with color selection. - * Enforces a maximum of 5 badges (REQ-F-21) and 20-character text limit (REQ-F-20). - */ function BadgeEditor({ badges, onChange, maxBadges = 5 }: BadgeEditorProps) { const [isAdding, setIsAdding] = useState(false); const [newBadgeText, setNewBadgeText] = useState(""); const [newBadgeColor, setNewBadgeColor] = useState("purple"); const textInputRef = useRef(null); - // Focus input when add form opens useEffect(() => { if (isAdding && textInputRef.current) { textInputRef.current.focus(); @@ -209,7 +163,6 @@ function BadgeEditor({ badges, onChange, maxBadges = 5 }: BadgeEditorProps) { const handleTextChange = useCallback( (e: React.ChangeEvent) => { - // Enforce max length at input level const value = e.target.value; if (value.length <= MAX_BADGE_TEXT_LENGTH) { setNewBadgeText(value); @@ -233,7 +186,6 @@ function BadgeEditor({ badges, onChange, maxBadges = 5 }: BadgeEditorProps) { return (
- {/* Existing badges list */} {badges.length > 0 && (
{badges.map((badge, index) => ( @@ -256,10 +208,8 @@ function BadgeEditor({ badges, onChange, maxBadges = 5 }: BadgeEditorProps) {
)} - {/* Add badge form or button */} {isAdding ? (
- {/* Color palette */}
{BADGE_COLORS.map((color) => (
- {/* Scrollable content area */}
- {/* Identity Settings Section */}

Identity

@@ -538,7 +483,6 @@ export function ConfigEditorDialog({

- {/* Discussion Settings Section */}

Discussion

@@ -558,22 +502,30 @@ export function ConfigEditorDialog({ onChange={(e) => setFormState((prev) => ({ ...prev, - discussionModel: - (e.target.value as "opus" | "sonnet" | "haiku") || - undefined, + discussionModel: e.target.value || undefined, })) } > - - - - + {availableModels.length === 0 ? ( + + ) : ( + <> + + {hasUnknownModel && ( + + )} + {availableModels.map((m) => ( + + ))} + + )}

- {/* Recent Discussions slider */}
- {/* Quotes per Week slider */}
- {/* Editing Settings Section */}

Editing

@@ -811,9 +755,7 @@ export function ConfigEditorDialog({

- {/* Footer with actions */}
- {/* Save error display (TASK-010) */} {saveError && (
{saveError} @@ -841,7 +783,6 @@ export function ConfigEditorDialog({
- {/* Unsaved changes confirmation dialog */} { + if (url === "/api/models") { + return new Response(JSON.stringify({ models: MOCK_MODELS }), { + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); +}); + +beforeAll(() => { global.fetch = mockFetch as typeof fetch; }); +afterAll(() => { mockFetch.mockRestore(); }); + afterEach(() => { cleanup(); }); @@ -38,7 +49,6 @@ describe("ConfigEditorDialog", () => { }; beforeEach(() => { - // Reset mocks before each test (defaultProps.onSave as ReturnType).mockClear?.(); (defaultProps.onCancel as ReturnType).mockClear?.(); }); @@ -90,11 +100,13 @@ describe("ConfigEditorDialog", () => { expect(subtitleInput.value).toBe("Test subtitle"); }); - it("discussion model dropdown shows value from initialConfig.discussionModel", () => { + it("discussion model dropdown shows value from initialConfig.discussionModel", async () => { render(); - const modelSelect = screen.getByLabelText("AI Model"); - expect(modelSelect.value).toBe("sonnet"); + await waitFor(() => { + const modelSelect = screen.getByLabelText("AI Model"); + expect(modelSelect.value).toBe("sonnet"); + }); }); it("promptsPerGeneration slider shows value from initialConfig", () => { @@ -788,30 +800,50 @@ describe("ConfigEditorDialog", () => { }); describe("dropdown interactions", () => { - it("discussion model can be changed to opus", () => { + it("discussion model can be changed to opus", async () => { render(); - const modelSelect = screen.getByLabelText("AI Model"); - fireEvent.change(modelSelect, { target: { value: "opus" } }); - - expect(modelSelect.value).toBe("opus"); + await waitFor(() => { + const modelSelect = screen.getByLabelText("AI Model"); + fireEvent.change(modelSelect, { target: { value: "opus" } }); + expect(modelSelect.value).toBe("opus"); + }); }); - it("discussion model can be changed to haiku", () => { + it("discussion model can be changed to haiku", async () => { render(); - const modelSelect = screen.getByLabelText("AI Model"); - fireEvent.change(modelSelect, { target: { value: "haiku" } }); - - expect(modelSelect.value).toBe("haiku"); + await waitFor(() => { + const modelSelect = screen.getByLabelText("AI Model"); + fireEvent.change(modelSelect, { target: { value: "haiku" } }); + expect(modelSelect.value).toBe("haiku"); + }); }); - it("displays all model options", () => { + it("displays all model options", async () => { render(); - expect(screen.getByText("Opus (Most capable)")).toBeDefined(); - expect(screen.getByText("Sonnet (Balanced)")).toBeDefined(); - expect(screen.getByText("Haiku (Fastest)")).toBeDefined(); + await waitFor(() => { + expect(screen.getByRole("option", { name: "opus" })).toBeDefined(); + expect(screen.getByRole("option", { name: "sonnet" })).toBeDefined(); + expect(screen.getByRole("option", { name: "haiku" })).toBeDefined(); + }); + }); + + it("shows unknown model option and selects it when initialConfig has an unrecognised model", async () => { + render( + + ); + + await waitFor(() => { + const unknownOption = screen.getByRole("option", { name: "unknown-model (unknown)" }); + expect(unknownOption).toBeDefined(); + const modelSelect = screen.getByLabelText("AI Model"); + expect(modelSelect.value).toBe("unknown-model"); + }); }); }); diff --git a/nextjs/lib/daemon/index.ts b/nextjs/lib/daemon/index.ts index f23238a5..857f6a32 100644 --- a/nextjs/lib/daemon/index.ts +++ b/nextjs/lib/daemon/index.ts @@ -15,3 +15,4 @@ export { export * as vaultClient from "./vaults"; export * as fileClient from "./files"; export * as sessionClient from "./sessions"; +export * from "./models"; diff --git a/nextjs/lib/daemon/models.ts b/nextjs/lib/daemon/models.ts new file mode 100644 index 00000000..9a11b47f --- /dev/null +++ b/nextjs/lib/daemon/models.ts @@ -0,0 +1,13 @@ +import { daemonFetch } from "./fetch"; + +export interface ModelEntry { + name: string; + provider: string; + modelId: string; +} + +export async function getModels(): Promise { + const res = await daemonFetch("/models"); + const json = (await res.json()) as { models: ModelEntry[] }; + return json.models; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4fed7cf5..e5839a7f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,21 +1,18 @@ /** * @memory-loop/shared * - * Shared types, schemas, and utilities for Memory Loop. - * Used by both the Next.js web app and the daemon process. + * Shared types, schemas, and utilities used by both the Next.js web app + * and the daemon process. */ -// Schemas and types export * from "./schemas/index"; -// Logger export { createLogger, setLogLevel } from "./logger"; export type { LogLevel } from "./logger"; -// Vault configuration types and resolvers -// NOTE: fileExists/directoryExists and resolveContentRoot are server-only. -// Import from "@memory-loop/shared/server" for those. -export type { VaultConfig, DiscussionModelLocal } from "./vault-config"; +// fileExists/directoryExists and resolveContentRoot are server-only. +// Import them from "@memory-loop/shared/server". +export type { VaultConfig } from "./vault-config"; export { CONFIG_FILE_NAME, SLASH_COMMANDS_FILE, @@ -28,8 +25,6 @@ export { DEFAULT_QUOTES_PER_WEEK, DEFAULT_RECENT_CAPTURES, DEFAULT_RECENT_DISCUSSIONS, - VALID_DISCUSSION_MODELS, - DEFAULT_DISCUSSION_MODEL, DEFAULT_ORDER, DEFAULT_CARDS_ENABLED, DEFAULT_VI_MODE, @@ -48,14 +43,12 @@ export { resolvePinnedAssets, resolveRecentCaptures, resolveRecentDiscussions, - resolveDiscussionModel, resolveOrder, resolveCardsEnabled, resolveViMode, slashCommandsEqual, } from "./vault-config"; -// File type utilities export { IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, @@ -70,14 +63,12 @@ export { encodeAssetPath, } from "./file-types"; -// Date formatting utilities export { formatDateForFilename, formatTimeForTimestamp, getDailyNoteFilename, } from "./date-utils"; -// Session types export type { SessionEvent, PendingPrompt, @@ -88,7 +79,6 @@ export type { } from "./session-types"; export { AlreadyProcessingError } from "./session-types"; -// Pair writing prompts export type { QuickActionType, AdvisoryActionType, @@ -111,7 +101,6 @@ export { buildDiscussPrompt, } from "./pair-writing-prompts"; -// Vault path helpers export type { ExtractedTitle } from "./vault-paths"; export { DEFAULT_INBOX_PATH, diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 08abd174..ca06787f 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -1,51 +1,42 @@ /** * Memory Loop Shared Types and Protocols * - * This package contains: - * - Zod schemas for WebSocket protocol validation - * - TypeScript types for Vault, Session, and Message models - * - Shared utilities for frontend and backend + * Zod schemas for protocol validation, TypeScript types for Vault/Session/Message + * models, and shared utilities for frontend and backend. */ export const VERSION = "0.1.0"; -// Core types -export type { VaultInfo, SessionMetadata, ErrorCode, StoredToolInvocation, ConversationMessage, Badge, BadgeColor, SaveConfigResult } from "./types"; +export type { + VaultInfo, + SessionMetadata, + ErrorCode, + StoredToolInvocation, + ConversationMessage, + Badge, + BadgeColor, + SaveConfigResult, +} from "./types"; -// Editable vault config types (from protocol) -export type { EditableVaultConfig, DiscussionModel } from "./protocol"; -export { EditableVaultConfigSchema, DiscussionModelSchema, EditableBadgeSchema } from "./protocol"; +export type { EditableVaultConfig } from "./protocol"; +export { EditableVaultConfigSchema, EditableBadgeSchema } from "./protocol"; -// Protocol schemas export { - // Vault Info VaultInfoSchema, ErrorCodeSchema, - // File browser schemas FileEntrySchema, - // Task schemas TaskCategorySchema, TaskEntrySchema, - // Recent notes schemas RecentNoteEntrySchema, - // Recent discussion schemas RecentDiscussionEntrySchema, - // Tool invocation schema ToolInvocationSchema, - // Conversation message schema ConversationMessageSchema, - // Inspiration schemas (used by REST API) InspirationItemSchema, - // Slash command schemas SlashCommandSchema, - // Search result schemas FileSearchResultSchema, ContentSearchResultSchema, ContextSnippetSchema, - - // Meeting state schema (used by REST API) MeetingStateSchema, - // Spaced repetition card schemas (used by REST API) ReviewResponseSchema, DueCardSchema, CardDetailSchema, @@ -53,7 +44,7 @@ export { ReviewResultSchema, ArchiveResponseSchema, DueCardsResponseSchema, - // Client -> Server schemas (WebSocket only) + // Client -> Server (WebSocket) SelectVaultMessageSchema, CreateVaultMessageSchema, DiscussionMessageSchema, @@ -62,7 +53,7 @@ export { AbortMessageSchema, PingMessageSchema, ClientMessageSchema, - // Server -> Client schemas (WebSocket only) + // Server -> Client (WebSocket) VaultListMessageSchema, VaultCreatedMessageSchema, SessionReadyMessageSchema, @@ -75,40 +66,26 @@ export { ErrorMessageSchema, PongMessageSchema, ServerMessageSchema, - // Validation utilities parseClientMessage, parseServerMessage, safeParseClientMessage, safeParseServerMessage, } from "./protocol"; -// Protocol message types (inferred from Zod schemas) export type { - // Conversation message type (for session messages) ConversationMessageProtocol, - // File browser types FileEntry, - // Task types TaskCategory, TaskEntry, - // Recent notes types RecentNoteEntry, - // Recent discussion types RecentDiscussionEntry, - // Tool invocation type ToolInvocation, - // Inspiration types (used by REST API) InspirationItem, - // Slash command types SlashCommand, - // Search result types FileSearchResult, ContentSearchResult, ContextSnippet, - - // Meeting types (used by REST API) MeetingState, - // Spaced repetition card types (used by REST API) ReviewResponse, DueCard, CardDetail, @@ -116,17 +93,15 @@ export type { ReviewResult, ArchiveResponse, DueCardsResponse, - // AskUserQuestion types AskUserQuestionOption, AskUserQuestionItem, AskUserQuestionResponseMessage, AskUserQuestionRequestMessage, - // Pair Writing types QuickActionType, QuickActionRequestMessage, AdvisoryActionType, AdvisoryActionRequestMessage, - // Client message types (WebSocket only) + // Client messages (WebSocket) SelectVaultMessage, CreateVaultMessage, DiscussionMessage, @@ -135,7 +110,7 @@ export type { AbortMessage, PingMessage, ClientMessage, - // Server message types (WebSocket only) + // Server messages (WebSocket) VaultListMessage, VaultCreatedMessage, SessionReadyMessage, diff --git a/packages/shared/src/schemas/protocol.ts b/packages/shared/src/schemas/protocol.ts index c2e99785..bccced1d 100644 --- a/packages/shared/src/schemas/protocol.ts +++ b/packages/shared/src/schemas/protocol.ts @@ -11,9 +11,6 @@ import { z } from "zod"; // Badge Schema // ============================================================================= -/** - * Schema for badge color enum - valid named colors for badges - */ export const BadgeColorSchema = z.enum([ "black", "purple", @@ -25,18 +22,12 @@ export const BadgeColorSchema = z.enum([ "yellow", ]); -/** - * Schema for Badge - custom badge configured in .memory-loop.json - */ export const BadgeSchema = z.object({ text: z.string().min(1, "Badge text is required"), color: BadgeColorSchema, }); -/** - * Schema for Badge with strict validation for editable config. - * Used when validating user input for badge editing (max 20 chars). - */ +// Stricter version used when validating user input for badge editing. export const EditableBadgeSchema = z.object({ text: z.string().min(1, "Badge text is required").max(20, "Badge text must be 20 characters or less"), color: BadgeColorSchema, @@ -46,20 +37,11 @@ export const EditableBadgeSchema = z.object({ // Editable Vault Config Schema // ============================================================================= -/** - * Schema for discussion model selection - the three Claude model tiers - */ -export const DiscussionModelSchema = z.enum(["opus", "sonnet", "haiku"]); - -/** - * Schema for editable vault configuration fields. - * All fields are optional to support partial updates. - * Constraints match the spec requirements. - */ +// All fields optional to support partial updates. Constraints match the spec. export const EditableVaultConfigSchema = z.object({ title: z.string().optional(), subtitle: z.string().optional(), - discussionModel: DiscussionModelSchema.optional(), + discussionModel: z.string().optional(), promptsPerGeneration: z.number().int().min(1).max(20).optional(), maxPoolSize: z.number().int().min(10).max(200).optional(), quotesPerWeek: z.number().int().min(0).max(7).optional(), @@ -75,9 +57,6 @@ export const EditableVaultConfigSchema = z.object({ // Vault Info Schema // ============================================================================= -/** - * Schema for VaultInfo - used in vault_list messages - */ export const VaultInfoSchema = z.object({ id: z.string().min(1, "Vault ID is required"), name: z.string().min(1, "Vault name is required"), @@ -90,7 +69,7 @@ export const VaultInfoSchema = z.object({ goalsPath: z.string().optional(), attachmentPath: z.string().min(1, "Attachment path is required"), setupComplete: z.boolean(), - discussionModel: DiscussionModelSchema.optional(), + discussionModel: z.string().optional(), promptsPerGeneration: z.number().int().positive(), maxPoolSize: z.number().int().positive(), quotesPerWeek: z.number().int().positive(), @@ -106,9 +85,6 @@ export const VaultInfoSchema = z.object({ // Error Code Schema // ============================================================================= -/** - * Schema for ErrorCode enum values - */ export const ErrorCodeSchema = z.enum([ "VAULT_NOT_FOUND", "VAULT_ACCESS_DENIED", @@ -128,42 +104,25 @@ export const ErrorCodeSchema = z.enum([ // File Browser Schemas // ============================================================================= -/** - * Schema for a file or directory entry in a vault listing - */ export const FileEntrySchema = z.object({ name: z.string().min(1, "Entry name is required"), type: z.enum(["file", "directory"]), path: z.string(), // Can be empty string for root entries }); -/** - * Schema for task category - indicates which directory the task was found in - */ export const TaskCategorySchema = z.enum(["inbox", "projects", "areas"]); -/** - * Schema for a task entry parsed from markdown files - * Tasks are lines matching /^\s*- \[(.)\] (.+)$/ - */ +// Tasks are lines matching /^\s*- \[(.)\] (.+)$/ export const TaskEntrySchema = z.object({ - /** Task text content (after checkbox) */ text: z.string(), - /** Checkbox state character: ' ', 'x', '/', '?', 'b', 'f' */ + // Checkbox state character: ' ', 'x', '/', '?', 'b', 'f' state: z.string().length(1, "State must be a single character"), - /** Relative file path from content root */ filePath: z.string().min(1, "File path is required"), - /** Line number in file (1-indexed) */ lineNumber: z.number().int().min(1, "Line number must be at least 1"), - /** File modification time (Unix timestamp in ms) for sorting */ fileMtime: z.number().int().min(0), - /** Category indicating source directory (inbox, projects, or areas) */ category: TaskCategorySchema, }); -/** - * Schema for a recent note entry in the inbox - */ export const RecentNoteEntrySchema = z.object({ id: z.string().min(1, "Entry ID is required"), text: z.string(), @@ -171,9 +130,6 @@ export const RecentNoteEntrySchema = z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format"), }); -/** - * Schema for a recent discussion entry (session summary) - */ export const RecentDiscussionEntrySchema = z.object({ sessionId: z.string().min(1, "Session ID is required"), preview: z.string(), @@ -186,16 +142,12 @@ export const RecentDiscussionEntrySchema = z.object({ // Slash Command Schema // ============================================================================= -/** - * Schema for a slash command available in the discussion interface - * Commands are sent from server to client in session_ready message - */ +// Sent from server to client in session_ready. export const SlashCommandSchema = z.object({ - /** Command name including "/" prefix (e.g., "/commit") */ + // Includes "/" prefix (e.g., "/commit"). name: z.string().min(2, "Command name must include / prefix and at least one character"), - /** User-facing description of what the command does */ description: z.string().min(1, "Description is required"), - /** Optional hint for expected arguments (e.g., "") */ + // Optional hint for expected arguments (e.g., ""). argumentHint: z.string().optional(), }); @@ -203,9 +155,6 @@ export const SlashCommandSchema = z.object({ // Tool Invocation Schema // ============================================================================= -/** - * Schema for a tool invocation within an assistant message - */ export const ToolInvocationSchema = z.object({ toolUseId: z.string().min(1, "Tool use ID is required"), toolName: z.string().min(1, "Tool name is required"), @@ -214,9 +163,6 @@ export const ToolInvocationSchema = z.object({ status: z.enum(["running", "complete"]), }); -/** - * Schema for a conversation message in session history - */ export const ConversationMessageSchema = z.object({ id: z.string().min(1, "Message ID is required"), role: z.enum(["user", "assistant"]), @@ -231,48 +177,26 @@ export const ConversationMessageSchema = z.object({ // Search Result Schemas // ============================================================================= -/** - * Schema for a file name search result - * Returned when searching by file name with fuzzy matching - */ export const FileSearchResultSchema = z.object({ - /** Relative path from content root */ path: z.string(), - /** File name only (without path) */ name: z.string(), - /** Match quality score (higher = better match) */ score: z.number(), - /** Character positions in name that matched the query */ matchPositions: z.array(z.number()), }); -/** - * Schema for a content search result - * Returned when searching within file contents - */ export const ContentSearchResultSchema = z.object({ - /** Relative path from content root */ path: z.string(), - /** File name only (without path) */ name: z.string(), - /** Number of matches found in this file */ matchCount: z.number().int().min(1), - /** Context snippets (populated on demand via get_snippets) */ + // Populated on demand via get_snippets. snippets: z.array(z.lazy(() => ContextSnippetSchema)).optional(), }); -/** - * Schema for a context snippet showing a matched line with surrounding context - * Used for content search result expansion - */ export const ContextSnippetSchema = z.object({ - /** Line number of the match (1-indexed) */ lineNumber: z.number().int().min(1), - /** The matched line content */ line: z.string(), - /** Up to 2 lines before the match */ + // Up to 2 lines before/after the match. contextBefore: z.array(z.string()), - /** Up to 2 lines after the match */ contextAfter: z.array(z.string()), }); @@ -280,28 +204,16 @@ export const ContextSnippetSchema = z.object({ // REST API Data Schemas // ============================================================================= -/** - * Schema for an inspiration item (used for both contextual prompts and quotes). - * Used by REST API responses. - */ +// Used for both contextual prompts and quotes. export const InspirationItemSchema = z.object({ text: z.string().min(1, "Inspiration text is required"), attribution: z.string().optional(), }); -/** - * Schema for active meeting state. - * Tracks the current meeting session if one is active. - * Used by REST API responses. - */ export const MeetingStateSchema = z.object({ - /** Whether a meeting is currently active */ isActive: z.boolean(), - /** Meeting title (set when meeting started) */ title: z.string().optional(), - /** Path to the meeting file (relative to content root) */ filePath: z.string().optional(), - /** ISO 8601 timestamp when meeting started */ startedAt: z.string().optional(), }); @@ -309,99 +221,50 @@ export const MeetingStateSchema = z.object({ // Spaced Repetition Card Schemas // ============================================================================= -/** - * Valid review responses for spaced repetition cards. - * Maps to SM-2 algorithm quality ratings: - * - again: Complete failure (q=0) - * - hard: Correct but with difficulty (q=3) - * - good: Correct with some effort (q=4) - * - easy: Perfect recall (q=5) - */ +// Maps to SM-2 algorithm quality ratings: +// again (q=0), hard (q=3), good (q=4), easy (q=5). export const ReviewResponseSchema = z.enum(["again", "hard", "good", "easy"]); -/** - * Schema for a due card preview (question only, no answer). - * Used in GET /cards/due response items. - */ +// Question-only preview (used in GET /cards/due response items). export const DueCardSchema = z.object({ - /** Unique card identifier (UUID) */ id: z.string().uuid(), - /** The question to display */ question: z.string().min(1, "Question is required"), - /** ISO 8601 date when card is due for review */ next_review: z.string(), - /** Path to the card file (relative to vault, e.g., 06_Metadata/memory-loop/cards/{id}.md) */ card_file: z.string(), }); -/** - * Schema for full card detail with answer. - * Used in GET /cards/:cardId response after revealing answer. - */ +// Full card with answer (used in GET /cards/:cardId response). export const CardDetailSchema = z.object({ - /** Unique card identifier (UUID) */ id: z.string().uuid(), - /** The question to display */ question: z.string().min(1, "Question is required"), - /** The answer to reveal */ answer: z.string().min(1, "Answer is required"), - /** SM-2 ease factor (default 2.5, adjusted based on performance) */ + // SM-2 ease factor (default 2.5, adjusted based on performance). ease_factor: z.number().min(1.3), - /** Days until next review */ interval: z.number().int().min(0), - /** Number of successful reviews in a row */ repetitions: z.number().int().min(0), - /** ISO 8601 timestamp of last review, null if never reviewed */ last_reviewed: z.string().nullable(), - /** ISO 8601 date when card is due for review */ next_review: z.string(), - /** Source file path if card was extracted from a note */ source_file: z.string().optional(), }); -/** - * Schema for review request body. - * Used in POST /cards/:cardId/review request. - */ export const ReviewRequestSchema = z.object({ - /** User's self-assessment of recall quality */ response: ReviewResponseSchema, }); -/** - * Schema for review result response. - * Used in POST /cards/:cardId/review response. - */ export const ReviewResultSchema = z.object({ - /** Card identifier */ id: z.string().uuid(), - /** Updated next review date (ISO 8601) */ next_review: z.string(), - /** Updated interval in days */ interval: z.number().int().min(0), - /** Updated ease factor */ ease_factor: z.number().min(1.3), }); -/** - * Schema for archive response. - * Used in POST /cards/:cardId/archive response. - */ export const ArchiveResponseSchema = z.object({ - /** Card identifier */ id: z.string().uuid(), - /** Confirmation that card was archived */ archived: z.literal(true), }); -/** - * Schema for due cards list response. - * Used in GET /cards/due response. - */ export const DueCardsResponseSchema = z.object({ - /** Array of due card previews */ cards: z.array(DueCardSchema), - /** Total count of due cards */ count: z.number().int().min(0), }); @@ -409,60 +272,37 @@ export const DueCardsResponseSchema = z.object({ // Client -> Server Message Schemas // ============================================================================= -/** - * Client requests to select a vault and start/resume a session - */ export const SelectVaultMessageSchema = z.object({ type: z.literal("select_vault"), vaultId: z.string().min(1, "Vault ID is required"), }); -/** - * Client sends a discussion message for the AI to respond to - */ export const DiscussionMessageSchema = z.object({ type: z.literal("discussion_message"), text: z.string().min(1, "Message text is required"), }); -/** - * Client requests to resume an existing session - */ export const ResumeSessionMessageSchema = z.object({ type: z.literal("resume_session"), sessionId: z.string().min(1, "Session ID is required"), }); -/** - * Client requests to start a new session (clearing context) - */ export const NewSessionMessageSchema = z.object({ type: z.literal("new_session"), }); -/** - * Client requests to abort the current operation - */ export const AbortMessageSchema = z.object({ type: z.literal("abort"), }); -/** - * Client sends a ping to keep the connection alive - */ export const PingMessageSchema = z.object({ type: z.literal("ping"), }); -/** - * Client responds to a tool permission request - * Sent in response to tool_permission_request from server - */ +// Response to tool_permission_request. export const ToolPermissionResponseMessageSchema = z.object({ type: z.literal("tool_permission_response"), - /** The tool use ID from the permission request */ toolUseId: z.string().min(1, "Tool use ID is required"), - /** Whether the user allows the tool to run */ allowed: z.boolean(), }); @@ -470,49 +310,29 @@ export const ToolPermissionResponseMessageSchema = z.object({ // AskUserQuestion Schemas // ============================================================================= -/** - * Schema for a single option in an AskUserQuestion question - */ export const AskUserQuestionOptionSchema = z.object({ - /** Display text for this option */ label: z.string().min(1, "Option label is required"), - /** Description explaining what this option means */ description: z.string(), }); -/** - * Schema for a single question in an AskUserQuestion request - */ export const AskUserQuestionItemSchema = z.object({ - /** The full question text to display */ question: z.string().min(1, "Question text is required"), - /** Short label for the question */ header: z.string().min(1, "Header is required"), - /** Available choices (2-4 options) */ + // 2-4 options per question. options: z.array(AskUserQuestionOptionSchema).min(2).max(4), - /** If true, users can select multiple options */ multiSelect: z.boolean(), }); -/** - * Client responds to an AskUserQuestion request - * Sent in response to ask_user_question_request from server - */ +// Response to ask_user_question_request. `answers` maps question text -> answer. export const AskUserQuestionResponseMessageSchema = z.object({ type: z.literal("ask_user_question_response"), - /** The tool use ID from the request */ toolUseId: z.string().min(1, "Tool use ID is required"), - /** Map of question text to selected answer(s) */ answers: z.record(z.string(), z.string()), }); -/** - * Client requests to create a new vault. - * The title will be converted to a safe directory name. - */ +// Title becomes the CLAUDE.md heading and is converted to a safe directory name. export const CreateVaultMessageSchema = z.object({ type: z.literal("create_vault"), - /** User-provided vault title (will become CLAUDE.md heading) */ title: z.string().min(1, "Vault title is required"), }); @@ -520,71 +340,46 @@ export const CreateVaultMessageSchema = z.object({ // Pair Writing Mode Client Messages // ============================================================================= -/** - * Action types for Quick Actions (transformative, all platforms) - * - tighten: Make more concise without losing meaning - * - embellish: Add detail, nuance, or context - * - correct: Fix typos and grammar only - * - polish: Correct + improve prose - */ +// Quick actions (transformative, all platforms): +// tighten - more concise without losing meaning +// embellish - add detail, nuance, or context +// correct - fix typos and grammar only +// polish - correct + improve prose export const QuickActionTypeSchema = z.enum(["tighten", "embellish", "correct", "polish"]); -/** - * Action types for Advisory Actions (Pair Writing Mode, desktop only) - * - validate: Fact-check the claim - * - critique: Analyze clarity, voice, structure - * - compare: Compare current text to snapshot - * - discuss: Discuss improvements or alternatives - */ +// Advisory actions (Pair Writing Mode, desktop only): +// validate - fact-check the claim +// critique - analyze clarity, voice, structure +// compare - compare current text to snapshot +// discuss - discuss improvements or alternatives export const AdvisoryActionTypeSchema = z.enum(["validate", "critique", "compare", "discuss"]); -/** - * Client requests a Quick Action on selected text (all platforms) - * Claude uses Read/Edit tools to modify the file directly - */ +// Claude uses Read/Edit tools to modify the file directly. export const QuickActionRequestMessageSchema = z.object({ type: z.literal("quick_action_request"), - /** The action to perform */ action: QuickActionTypeSchema, - /** The selected text to transform */ selection: z.string().min(1, "Selection is required"), - /** Paragraph before the selection (for context) */ contextBefore: z.string(), - /** Paragraph after the selection (for context) */ contextAfter: z.string(), - /** Path to the file being edited (relative to content root) */ filePath: z.string().min(1, "File path is required"), - /** 1-indexed line number where selection starts */ selectionStartLine: z.number().int().min(1, "Selection start line must be at least 1"), - /** 1-indexed line number where selection ends */ selectionEndLine: z.number().int().min(1, "Selection end line must be at least 1"), - /** Total lines in the document (for position hint calculation) */ + // Used for position hint calculation. totalLines: z.number().int().min(1, "Total lines must be at least 1"), }); -/** - * Client requests an Advisory Action on selected text (Pair Writing Mode, desktop) - * Response appears in conversation pane; user manually applies changes - */ +// Response appears in conversation pane; user manually applies changes. export const AdvisoryActionRequestMessageSchema = z.object({ type: z.literal("advisory_action_request"), - /** The advisory action to perform */ action: AdvisoryActionTypeSchema, - /** The selected text to analyze */ selection: z.string().min(1, "Selection is required"), - /** Paragraph before the selection (for context) */ contextBefore: z.string(), - /** Paragraph after the selection (for context) */ contextAfter: z.string(), - /** Path to the file being edited (relative to content root) */ filePath: z.string().min(1, "File path is required"), - /** 1-indexed line number where selection starts */ selectionStartLine: z.number().int().min(1, "Selection start line must be at least 1"), - /** 1-indexed line number where selection ends */ selectionEndLine: z.number().int().min(1, "Selection end line must be at least 1"), - /** Total lines in the document (for position hint calculation) */ totalLines: z.number().int().min(1, "Total lines must be at least 1"), - /** For compare action: the corresponding text from the snapshot */ + // For compare action: the corresponding text from the snapshot. snapshotSelection: z.string().optional(), }); @@ -592,38 +387,25 @@ export const AdvisoryActionRequestMessageSchema = z.object({ // Memory Extraction Client Messages // ============================================================================= -/** - * Client requests current extraction prompt with override status (REQ-F-15) - * Response: extraction_prompt_content message - */ +// REQ-F-15: response is extraction_prompt_content. export const GetExtractionPromptMessageSchema = z.object({ type: z.literal("get_extraction_prompt"), }); -/** - * Client requests to save extraction prompt (REQ-F-16) - * Creates user override at ~/.config/memory-loop/extraction-prompt.md if needed - * Response: extraction_prompt_saved message - */ +// REQ-F-16: creates user override at ~/.config/memory-loop/extraction-prompt.md +// if needed. Response is extraction_prompt_saved. export const SaveExtractionPromptMessageSchema = z.object({ type: z.literal("save_extraction_prompt"), - /** Updated extraction prompt content */ content: z.string(), }); -/** - * Client requests to reset extraction prompt to default (REQ-F-16) - * Removes user override at ~/.config/memory-loop/extraction-prompt.md - * Response: extraction_prompt_reset message - */ +// REQ-F-16: removes the user override file. Response is extraction_prompt_reset. export const ResetExtractionPromptMessageSchema = z.object({ type: z.literal("reset_extraction_prompt"), }); -/** - * Client requests to manually trigger extraction (for testing/debug) - * Response: extraction_status messages with progress updates - */ +// Manually triggers extraction (for testing/debug). Response stream is +// extraction_status messages with progress updates. export const TriggerExtractionMessageSchema = z.object({ type: z.literal("trigger_extraction"), }); @@ -632,63 +414,35 @@ export const TriggerExtractionMessageSchema = z.object({ // Card Generator Client Messages // ============================================================================= -/** - * Client requests current card generator config with requirements override status - * Response: card_generator_config_content message - */ export const GetCardGeneratorConfigMessageSchema = z.object({ type: z.literal("get_card_generator_config"), }); -/** - * Client requests to save card generator requirements (creates user override) - * Response: card_generator_requirements_saved message - */ export const SaveCardGeneratorRequirementsMessageSchema = z.object({ type: z.literal("save_card_generator_requirements"), - /** Updated requirements content */ content: z.string(), }); -/** - * Client requests to save card generator config (byte limit) - * Response: card_generator_config_saved message - */ export const SaveCardGeneratorConfigMessageSchema = z.object({ type: z.literal("save_card_generator_config"), - /** Weekly byte limit for card generation */ - weeklyByteLimit: z.number().int().min(102400).max(10485760), // 100KB - 10MB + // 100KB - 10MB. + weeklyByteLimit: z.number().int().min(102400).max(10485760), }); -/** - * Client requests to reset card generator requirements to default - * Removes user override at ~/.config/memory-loop/card-generator-requirements.md - * Response: card_generator_requirements_reset message - */ +// Removes user override at ~/.config/memory-loop/card-generator-requirements.md. export const ResetCardGeneratorRequirementsMessageSchema = z.object({ type: z.literal("reset_card_generator_requirements"), }); -/** - * Client requests to manually trigger card generation - * Bypasses "already ran this week" check, uses remaining weekly budget - * Response: card_generation_status messages with progress updates - */ +// Bypasses "already ran this week" check, uses remaining weekly budget. export const TriggerCardGenerationMessageSchema = z.object({ type: z.literal("trigger_card_generation"), }); -/** - * Client requests current card generation status - * Response: card_generation_status message - */ export const GetCardGenerationStatusMessageSchema = z.object({ type: z.literal("get_card_generation_status"), }); -/** - * Discriminated union of all client message types - */ export const ClientMessageSchema = z.discriminatedUnion("type", [ SelectVaultMessageSchema, CreateVaultMessageSchema, @@ -699,7 +453,6 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [ PingMessageSchema, ToolPermissionResponseMessageSchema, AskUserQuestionResponseMessageSchema, - // Pair Writing Mode QuickActionRequestMessageSchema, AdvisoryActionRequestMessageSchema, @@ -721,48 +474,34 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [ // Server -> Client Message Schemas // ============================================================================= -/** - * Server sends the list of available vaults - */ export const VaultListMessageSchema = z.object({ type: z.literal("vault_list"), vaults: z.array(VaultInfoSchema), }); -/** - * Server confirms session is ready - * Note: sessionId can be empty when vault is first selected (session created lazily) - * When resuming a session, messages contains the conversation history. - */ +// `sessionId` may be empty when a vault is first selected; the session is +// created on the first discussion_message. On resume, `messages` carries +// the conversation history. export const SessionReadyMessageSchema = z.object({ type: z.literal("session_ready"), - sessionId: z.string(), // Can be empty - session created on first discussion_message + sessionId: z.string(), vaultId: z.string().min(1), - messages: z.array(ConversationMessageSchema).optional(), // Sent on resume - createdAt: z.string().optional(), // ISO 8601 timestamp of session creation - slashCommands: z.array(SlashCommandSchema).optional(), // Available slash commands + messages: z.array(ConversationMessageSchema).optional(), + createdAt: z.string().optional(), + slashCommands: z.array(SlashCommandSchema).optional(), }); -/** - * Server signals start of AI response - */ export const ResponseStartMessageSchema = z.object({ type: z.literal("response_start"), messageId: z.string().min(1), }); -/** - * Server sends a chunk of the AI response - */ export const ResponseChunkMessageSchema = z.object({ type: z.literal("response_chunk"), messageId: z.string().min(1), content: z.string(), // Can be empty for whitespace-only chunks }); -/** - * Server signals end of AI response - */ export const ResponseEndMessageSchema = z.object({ type: z.literal("response_end"), messageId: z.string().min(1), @@ -770,82 +509,54 @@ export const ResponseEndMessageSchema = z.object({ durationMs: z.number().int().min(0).optional(), }); -/** - * Server signals start of tool invocation - */ export const ToolStartMessageSchema = z.object({ type: z.literal("tool_start"), toolName: z.string().min(1), toolUseId: z.string().min(1), }); -/** - * Server sends tool input parameters - */ export const ToolInputMessageSchema = z.object({ type: z.literal("tool_input"), toolUseId: z.string().min(1), input: z.unknown(), }); -/** - * Server signals end of tool invocation with output - */ export const ToolEndMessageSchema = z.object({ type: z.literal("tool_end"), toolUseId: z.string().min(1), output: z.unknown(), }); -/** - * Server sends an error message - */ export const ErrorMessageSchema = z.object({ type: z.literal("error"), code: ErrorCodeSchema, message: z.string().min(1, "Error message is required"), }); -/** - * Server responds to ping - */ export const PongMessageSchema = z.object({ type: z.literal("pong"), }); -/** - * Server requests permission from the user before running a tool - * The client should display a dialog and respond with tool_permission_response - */ +// Client should display a dialog and respond with tool_permission_response. export const ToolPermissionRequestMessageSchema = z.object({ type: z.literal("tool_permission_request"), - /** Unique identifier for this tool invocation */ toolUseId: z.string().min(1, "Tool use ID is required"), - /** Name of the tool being requested */ toolName: z.string().min(1, "Tool name is required"), - /** Tool input parameters for user review */ input: z.unknown(), }); -/** - * Server requests user input via AskUserQuestion tool - * The client should display a multi-question dialog and respond with ask_user_question_response - */ +// Client should display a multi-question dialog and respond with +// ask_user_question_response. export const AskUserQuestionRequestMessageSchema = z.object({ type: z.literal("ask_user_question_request"), - /** Unique identifier for this tool invocation */ toolUseId: z.string().min(1, "Tool use ID is required"), - /** Array of questions to present to the user (1-4 questions) */ + // 1-4 questions. questions: z.array(AskUserQuestionItemSchema).min(1).max(4), }); -/** - * Server confirms vault was created successfully. - * Response to create_vault request. - */ +// Response to create_vault. export const VaultCreatedMessageSchema = z.object({ type: z.literal("vault_created"), - /** The newly created vault info */ vault: VaultInfoSchema, }); @@ -853,72 +564,41 @@ export const VaultCreatedMessageSchema = z.object({ // Memory Extraction Schemas // ============================================================================= -/** - * Schema for extraction status enum values - * - idle: No extraction running - * - running: Extraction in progress - * - complete: Extraction finished successfully - * - error: Extraction failed - */ export const ExtractionStatusValueSchema = z.enum(["idle", "running", "complete", "error"]); -/** - * Server sends extraction prompt content (REQ-F-15) - * Response to get_extraction_prompt request - */ +// REQ-F-15: response to get_extraction_prompt. +// `isOverride` is true when reading from ~/.config/memory-loop/extraction-prompt.md. export const ExtractionPromptContentMessageSchema = z.object({ type: z.literal("extraction_prompt_content"), - /** Extraction prompt content */ content: z.string(), - /** True if using user override at ~/.config/memory-loop/extraction-prompt.md */ isOverride: z.boolean(), }); -/** - * Server confirms extraction prompt was saved (REQ-F-16) - * Response to save_extraction_prompt request - */ +// REQ-F-16: response to save_extraction_prompt. export const ExtractionPromptSavedMessageSchema = z.object({ type: z.literal("extraction_prompt_saved"), - /** Whether the save was successful */ success: z.boolean(), - /** True if this created/updated user override */ isOverride: z.boolean(), - /** Error message if success is false */ error: z.string().optional(), }); -/** - * Server confirms extraction prompt was reset to default (REQ-F-16) - * Response to reset_extraction_prompt request - */ +// REQ-F-16: response to reset_extraction_prompt. +// `content` carries the default prompt so the UI can update without refetching. export const ExtractionPromptResetMessageSchema = z.object({ type: z.literal("extraction_prompt_reset"), - /** Whether the reset was successful */ success: z.boolean(), - /** The default prompt content (sent so UI can update without fetching again) */ content: z.string(), - /** Error message if success is false */ error: z.string().optional(), }); -/** - * Server sends extraction status updates - * Sent during extraction run (triggered manually or scheduled) - */ +// Sent during extraction run (triggered manually or scheduled). export const ExtractionStatusMessageSchema = z.object({ type: z.literal("extraction_status"), - /** Current extraction status */ status: ExtractionStatusValueSchema, - /** Progress percentage (0-100) when status is "running" */ progress: z.number().min(0).max(100).optional(), - /** Human-readable status message */ message: z.string().optional(), - /** Error details when status is "error" */ error: z.string().optional(), - /** Number of transcripts processed (on completion) */ transcriptsProcessed: z.number().int().min(0).optional(), - /** Number of facts extracted (on completion) */ factsExtracted: z.number().int().min(0).optional(), }); @@ -926,94 +606,46 @@ export const ExtractionStatusMessageSchema = z.object({ // Card Generator Server Messages // ============================================================================= -/** - * Schema for card generation status enum values - * - idle: No generation running - * - running: Generation in progress - * - complete: Generation finished successfully - * - error: Generation failed - */ export const CardGenerationStatusValueSchema = z.enum(["idle", "running", "complete", "error"]); -/** - * Server sends card generator config content - * Response to get_card_generator_config request - */ export const CardGeneratorConfigContentMessageSchema = z.object({ type: z.literal("card_generator_config_content"), - /** Requirements prompt content */ requirements: z.string(), - /** True if using user override for requirements */ isOverride: z.boolean(), - /** Weekly byte limit for card generation */ weeklyByteLimit: z.number().int().min(0), - /** Bytes used this week */ weeklyBytesUsed: z.number().int().min(0), }); -/** - * Server confirms card generator requirements were saved - * Response to save_card_generator_requirements request - */ export const CardGeneratorRequirementsSavedMessageSchema = z.object({ type: z.literal("card_generator_requirements_saved"), - /** Whether the save was successful */ success: z.boolean(), - /** True if this created/updated user override */ isOverride: z.boolean(), - /** Error message if success is false */ error: z.string().optional(), }); -/** - * Server confirms card generator config was saved - * Response to save_card_generator_config request - */ export const CardGeneratorConfigSavedMessageSchema = z.object({ type: z.literal("card_generator_config_saved"), - /** Whether the save was successful */ success: z.boolean(), - /** Error message if success is false */ error: z.string().optional(), }); -/** - * Server confirms card generator requirements were reset to default - * Response to reset_card_generator_requirements request - */ export const CardGeneratorRequirementsResetMessageSchema = z.object({ type: z.literal("card_generator_requirements_reset"), - /** Whether the reset was successful */ success: z.boolean(), - /** The default requirements content */ content: z.string(), - /** Error message if success is false */ error: z.string().optional(), }); -/** - * Server sends card generation status updates - * Sent during generation run (triggered manually or scheduled) - */ export const CardGenerationStatusMessageSchema = z.object({ type: z.literal("card_generation_status"), - /** Current generation status */ status: CardGenerationStatusValueSchema, - /** Human-readable status message */ message: z.string().optional(), - /** Error details when status is "error" */ error: z.string().optional(), - /** Number of files processed (on completion) */ filesProcessed: z.number().int().min(0).optional(), - /** Number of cards created (on completion) */ cardsCreated: z.number().int().min(0).optional(), - /** Bytes processed (on completion) */ bytesProcessed: z.number().int().min(0).optional(), }); -/** - * Discriminated union of all server message types - */ export const ServerMessageSchema = z.discriminatedUnion("type", [ VaultListMessageSchema, VaultCreatedMessageSchema, @@ -1028,7 +660,6 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [ PongMessageSchema, ToolPermissionRequestMessageSchema, AskUserQuestionRequestMessageSchema, - // Memory Extraction ExtractionPromptContentMessageSchema, ExtractionPromptSavedMessageSchema, @@ -1046,49 +677,33 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [ // Inferred TypeScript Types // ============================================================================= -// File browser types export type FileEntry = z.infer; -// Task types export type TaskCategory = z.infer; export type TaskEntry = z.infer; -// Recent notes types export type RecentNoteEntry = z.infer; - -// Recent discussion types export type RecentDiscussionEntry = z.infer; -// Slash command type export type SlashCommand = z.infer; -// Tool invocation type export type ToolInvocation = z.infer; - -// Conversation message type export type ConversationMessageProtocol = z.infer; -// Search result types export type FileSearchResult = z.infer; export type ContentSearchResult = z.infer; export type ContextSnippet = z.infer; -// Badge types export type Badge = z.infer; export type BadgeColor = z.infer; export type EditableBadge = z.infer; -// Vault config types -export type DiscussionModel = z.infer; export type EditableVaultConfig = z.infer; -// Meeting types export type MeetingState = z.infer; -// Inspiration types (used by REST API) export type InspirationItem = z.infer; -// Spaced repetition card types (used by REST API) export type ReviewResponse = z.infer; export type DueCard = z.infer; export type CardDetail = z.infer; @@ -1115,10 +730,13 @@ export type QuickActionType = z.infer; export type AdvisoryActionType = z.infer; export type QuickActionRequestMessage = z.infer; export type AdvisoryActionRequestMessage = z.infer; + +// Memory Extraction client message types export type GetExtractionPromptMessage = z.infer; export type SaveExtractionPromptMessage = z.infer; export type ResetExtractionPromptMessage = z.infer; export type TriggerExtractionMessage = z.infer; + // Card Generator client message types export type GetCardGeneratorConfigMessage = z.infer; export type SaveCardGeneratorRequirementsMessage = z.infer; @@ -1142,11 +760,14 @@ export type ErrorMessage = z.infer; export type PongMessage = z.infer; export type ToolPermissionRequestMessage = z.infer; export type AskUserQuestionRequestMessage = z.infer; + +// Memory Extraction server message types export type ExtractionStatusValue = z.infer; export type ExtractionPromptContentMessage = z.infer; export type ExtractionPromptSavedMessage = z.infer; export type ExtractionPromptResetMessage = z.infer; export type ExtractionStatusMessage = z.infer; + // Card Generator server message types export type CardGenerationStatusValue = z.infer; export type CardGeneratorConfigContentMessage = z.infer; @@ -1160,32 +781,20 @@ export type ServerMessage = z.infer; // Validation Utilities // ============================================================================= -/** - * Parse and validate a client message from JSON - * @throws ZodError if validation fails - */ +/** Parse a client message. Throws ZodError if validation fails. */ export function parseClientMessage(data: unknown): ClientMessage { return ClientMessageSchema.parse(data); } -/** - * Parse and validate a server message from JSON - * @throws ZodError if validation fails - */ +/** Parse a server message. Throws ZodError if validation fails. */ export function parseServerMessage(data: unknown): ServerMessage { return ServerMessageSchema.parse(data); } -/** - * Safely parse a client message, returning success/error result - */ export function safeParseClientMessage(data: unknown) { return ClientMessageSchema.safeParse(data); } -/** - * Safely parse a server message, returning success/error result - */ export function safeParseServerMessage(data: unknown) { return ServerMessageSchema.safeParse(data); } diff --git a/packages/shared/src/schemas/types.ts b/packages/shared/src/schemas/types.ts index f92658cb..1852fcd1 100644 --- a/packages/shared/src/schemas/types.ts +++ b/packages/shared/src/schemas/types.ts @@ -1,13 +1,12 @@ /** * Memory Loop Shared Types * - * Core type definitions for Vault and Session models. - * These types are used by both frontend and backend. + * Core type definitions for Vault, Session, and Message models. + * Used by both frontend and backend. */ /** - * Named colors for custom badges. - * These map to theme CSS variables for consistent styling. + * Named colors for custom badges. Map to theme CSS variables. */ export type BadgeColor = | "black" @@ -19,12 +18,6 @@ export type BadgeColor = | "green" | "yellow"; -/** - * A custom badge configured in .memory-loop.json. - * - * @property text - The badge label text - * @property color - Named color from the theme palette - */ export interface Badge { text: string; color: BadgeColor; @@ -33,24 +26,10 @@ export interface Badge { /** * Information about an Obsidian vault discovered by the backend. * - * @property id - Directory name (unique identifier) - * @property name - Human-readable name (title portion before " - " or full heading) - * @property subtitle - Optional subtitle (portion after " - " in heading) - * @property path - Absolute path to the vault root directory - * @property hasClaudeMd - Whether the vault has a CLAUDE.md file - * @property contentRoot - Absolute path to content root (may differ from path if configured) - * @property inboxPath - Resolved inbox location for daily notes (relative to contentRoot) - * @property metadataPath - Path to metadata directory (relative to contentRoot) - * @property goalsPath - Path to goals.md if it exists (relative to contentRoot) - * @property attachmentPath - Path to attachments directory for uploads (relative to contentRoot) - * @property setupComplete - Whether vault setup has been completed (marker file exists) - * @property promptsPerGeneration - Number of prompts to generate per cycle (default: 5) - * @property maxPoolSize - Maximum items to keep in inspiration pools (default: 50) - * @property quotesPerWeek - Number of quotes to generate per week (default: 1) - * @property badges - Custom badges configured in .memory-loop.json - * @property order - Display order for vault selection (lower values first, Infinity for unset) - * @property cardsEnabled - Whether spaced repetition card discovery is enabled (default: true) - * @property viMode - Whether vi mode is enabled for Pair Writing editor (default: false) + * `path` is the vault root; `contentRoot` is the content directory (may differ + * if configured). All sub-paths (inboxPath, metadataPath, etc.) are relative + * to `contentRoot`. `order` may be `Infinity` for vaults without an explicit + * order, sorting them last. */ export interface VaultInfo { id: string; @@ -64,7 +43,7 @@ export interface VaultInfo { goalsPath?: string; attachmentPath: string; setupComplete: boolean; - discussionModel?: "opus" | "sonnet" | "haiku"; + discussionModel?: string; promptsPerGeneration: number; maxPoolSize: number; quotesPerWeek: number; @@ -76,15 +55,6 @@ export interface VaultInfo { viMode: boolean; } -/** - * A tool invocation within an assistant message. - * - * @property toolUseId - Unique ID for this tool invocation - * @property toolName - Name of the tool that was invoked - * @property input - Tool input parameters (optional) - * @property output - Tool output/result (optional) - * @property status - Whether the tool is running or complete - */ export interface StoredToolInvocation { toolUseId: string; toolName: string; @@ -96,15 +66,8 @@ export interface StoredToolInvocation { /** * A message in the conversation history. * - * Stored server-side in session files and sent to frontend on resume. - * - * @property id - Unique message ID - * @property role - Who sent the message - * @property content - Message text content - * @property timestamp - ISO 8601 timestamp - * @property toolInvocations - Tool invocations for assistant messages (optional) - * @property contextUsage - Percentage of context window used (0-100, assistant messages only) - * @property durationMs - Turn duration in milliseconds (assistant messages only) + * Stored server-side in session files and sent to the frontend on resume. + * `contextUsage` and `durationMs` are only set for assistant messages. */ export interface ConversationMessage { id: string; @@ -119,18 +82,8 @@ export interface ConversationMessage { /** * Metadata for a Claude Agent SDK session. * - * Session data is stored in `.memory-loop/sessions/` as JSON files. - * The actual conversation state is managed by the Claude Agent SDK. - * - * @property id - Claude Agent SDK session ID - * @property vaultId - Vault directory name - * @property vaultPath - Absolute path to the vault - * @property createdAt - ISO 8601 timestamp of session creation - * @property lastActiveAt - ISO 8601 timestamp of last activity - * @property messages - Conversation history for this session - * @property activeModel - Model identifier captured from SDK (optional) - * @property transcriptPath - Path to transcript markdown file in vault (optional) - * @property piSessionPath - Absolute path to the pi-agent JSONL session file (optional) + * Stored as JSON in `.memory-loop/sessions/`. Conversation state lives in + * the SDK; we persist enough to resume and render history. */ export interface SessionMetadata { id: string; @@ -144,19 +97,10 @@ export interface SessionMetadata { piSessionPath?: string; } -/** - * Result type for vault config save operations. - */ export type SaveConfigResult = | { success: true } | { success: false; error: string }; -/** - * Error codes for the WebSocket protocol. - * - * These codes provide structured error information for clients - * to handle specific error conditions appropriately. - */ export type ErrorCode = | "VAULT_NOT_FOUND" | "VAULT_ACCESS_DENIED" diff --git a/packages/shared/src/vault-config.ts b/packages/shared/src/vault-config.ts index 808675c3..5334a50b 100644 --- a/packages/shared/src/vault-config.ts +++ b/packages/shared/src/vault-config.ts @@ -1,15 +1,18 @@ /** * Vault Configuration Types and Resolvers * - * Pure types and derivation functions for vault configuration. - * No I/O operations. Used by both daemon and nextjs. + * Pure types and derivation functions for vault configuration. No I/O. + * Used by both daemon and nextjs. + * + * NOTE: resolveContentRoot uses node:path for security (normalize/join) and + * lives in vault-config-server.ts, exported from "@memory-loop/shared/server". */ import type { Badge, BadgeColor } from "./schemas/types"; +import type { SlashCommand } from "./schemas/protocol"; /** - * Per-vault configuration options. - * All paths are relative to the vault root directory. + * Per-vault configuration. All paths are relative to the vault root. */ export interface VaultConfig { title?: string; @@ -33,8 +36,6 @@ export interface VaultConfig { viMode?: boolean; } -// --- Constants --- - export const CONFIG_FILE_NAME = ".memory-loop.json"; export const SLASH_COMMANDS_FILE = ".memory-loop/slash-commands.json"; export const DEFAULT_METADATA_PATH = "06_Metadata/memory-loop"; @@ -46,9 +47,6 @@ export const DEFAULT_MAX_POOL_SIZE = 50; export const DEFAULT_QUOTES_PER_WEEK = 1; export const DEFAULT_RECENT_CAPTURES = 5; export const DEFAULT_RECENT_DISCUSSIONS = 5; -export const VALID_DISCUSSION_MODELS = ["opus", "sonnet", "haiku"] as const; -export type DiscussionModelLocal = (typeof VALID_DISCUSSION_MODELS)[number]; -export const DEFAULT_DISCUSSION_MODEL: DiscussionModelLocal = "opus"; export const DEFAULT_ORDER = 999999; export const DEFAULT_CARDS_ENABLED = true; export const DEFAULT_VI_MODE = false; @@ -64,10 +62,6 @@ export const VALID_BADGE_COLORS: BadgeColor[] = [ "yellow", ]; -// --- Resolver functions --- -// NOTE: resolveContentRoot uses node:path for security (normalize/join). -// It lives in vault-config-server.ts and is exported from @memory-loop/shared/server. - export function resolveMetadataPath(config: VaultConfig): string { return config.metadataPath ?? DEFAULT_METADATA_PATH; } @@ -124,10 +118,6 @@ export function resolveRecentDiscussions(config: VaultConfig): number { return config.recentDiscussions ?? DEFAULT_RECENT_DISCUSSIONS; } -export function resolveDiscussionModel(config: VaultConfig): DiscussionModelLocal { - return (config.discussionModel as DiscussionModelLocal | undefined) ?? DEFAULT_DISCUSSION_MODEL; -} - export function resolveOrder(config: VaultConfig): number { return config.order ?? DEFAULT_ORDER; } @@ -140,10 +130,6 @@ export function resolveViMode(config: VaultConfig): boolean { return config.viMode ?? DEFAULT_VI_MODE; } -// --- Utility functions --- - -import type { SlashCommand } from "./schemas/protocol"; - export function slashCommandsEqual( a: SlashCommand[] | undefined, b: SlashCommand[] | undefined