From 3ac8bc9f93a388a0dc74aedcfa7f5514dccaabd4 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:07:14 +0800 Subject: [PATCH 1/9] refactor: rename memory bookmarks from anchors to tesserae 'Anchor' already named an unrelated retrieval concept in the codebase (surface anchors: explicit quoted phrases/paths/IDs indexed for exact-match retrieval), and Pi's task-anchor plus the reasoning-workspace support-anchor concepts collide with the same word. Rename the memory-bookmark feature to 'tessera' (plural tesserae) so each concept owns an unambiguous term. Core: - types: AnchorRecord/Input/Hit -> TesseraRecord/Input/Hit, ANCHOR_REF_MARKER -> TESSERA_REF_MARKER (value tessera_ref) - schema: anchors table/anchors_fts/triggers -> tesserae (content-anchored bookmark rows); forward-migration renames a pre-rename 'anchors' table in place and rewrites 'anchor_ref' markers to 'tessera_ref' - store: searchAnchors/getAnchorsByIds -> searchTesserae/getTesseraeByIds; writes plan/insert helpers renamed; context.tesserae replaces context.anchors CLI/RPC/integration: - --anchor option -> --tessera; ANCHORS: render header -> TESSERAE: - agent-surface renders 'tessera=path:line' instead of 'anchor=' - search projection exposes context.tesserae DSH plugin: nmg_remember schema exposes tesserae (bookmarks) instead of anchors. Docs: design file renamed memory-anchors-design.md -> memory-tesserae-design.md with terminology note; superseded link updated. Verification: agent:verify all blocking checks passed (check, test:product, build, docs:check, verify:static, complexity gate); 776 product tests green. --- docs/design/file-content-source-design.md | 9 +- ...rs-design.md => memory-tesserae-design.md} | 118 ++++++++++-------- dsh/dsh-nmg/src/plugin/index.ts | 4 +- src/cli/commands.ts | 22 ++-- src/cli/main.ts | 18 +-- src/cli/protocol.ts | 6 +- src/cli/service.ts | 63 +++++----- src/core/store/retrieval.ts | 22 ++-- src/core/store/schema.ts | 62 ++++++--- src/core/store/writes.ts | 62 ++++----- src/core/types.ts | 36 +++--- src/integration/agent-surface.ts | 10 +- src/integration/search-projection.ts | 8 +- .../{anchors.test.ts => tesserae.test.ts} | 62 ++++----- 14 files changed, 274 insertions(+), 228 deletions(-) rename docs/design/{memory-anchors-design.md => memory-tesserae-design.md} (61%) rename tests/core/{anchors.test.ts => tesserae.test.ts} (55%) diff --git a/docs/design/file-content-source-design.md b/docs/design/file-content-source-design.md index d984f3a..99ded38 100644 --- a/docs/design/file-content-source-design.md +++ b/docs/design/file-content-source-design.md @@ -2,10 +2,11 @@ **Status:** superseded **Updated:** 2026-09-01 -**Superseded by:** [memory-anchors-design.md](memory-anchors-design.md) — the -full-text file index is dropped in favor of sparse, Agent-authored anchors as an -independent searchable source. This document is kept for lineage; its -"files are not memory" and separated-presentation conclusions carry forward. +**Superseded by:** [memory-tesserae-design.md](memory-tesserae-design.md) — the +full-text file index is dropped in favor of sparse, Agent-authored tesserae +(bookmarks) as an independent searchable source. This document is kept for +lineage; its "files are not memory" and separated-presentation conclusions +carry forward. This document proposes giving `nmg search` a second content source — the project's own files and documents — so an Agent does not have to re-discover the diff --git a/docs/design/memory-anchors-design.md b/docs/design/memory-tesserae-design.md similarity index 61% rename from docs/design/memory-anchors-design.md rename to docs/design/memory-tesserae-design.md index 846bef5..a39bda2 100644 --- a/docs/design/memory-anchors-design.md +++ b/docs/design/memory-tesserae-design.md @@ -1,4 +1,4 @@ -# Memory anchors: bookmarks as a searchable source +# Memory tesserae: bookmarks as a searchable source **Status:** proposed **Updated:** 2026-09-02 @@ -8,6 +8,16 @@ This document supersedes the file-content-source design "how an Agent reaches file content through NMG". It records a first-principles redesign reached through an extended design discussion (surveyed 2026-09-02). +> **Terminology note.** This design originally called its bookmarks "anchors" +> and shipped under that name. The implementation was renamed to *tesserae* +> (singular *tessera*; from the Latin tessera hospitalis — a token broken in two +> so that matching the halves proves identity) because "anchor" already named an +> unrelated retrieval concept in the codebase (`surface anchors`: explicit +> quoted phrases/paths/IDs indexed for exact-match retrieval). This document +> uses the shipped name. "Anchored" as a general adjective (content-anchored, +> hash-anchored) and references to other systems' anchors keep their original +> meaning. + ## 1. Problem The Agent repeatedly "searches around" for things it already knows exist. NMG @@ -19,7 +29,7 @@ The previous answer was a **file content index**: passively scan files, index their full text, and search them as a second source ([file-content-source-design.md](file-content-source-design.md)). Experience with the MVP showed this direction is usable but not good: whole-file blobs, -trigram fragments with no anchors, and — decisively — **a whole index to +trigram fragments with no surface anchors, and — decisively — **a whole index to maintain** (scope file, incremental crawler, content hashes, file FTS, scope observer). The maintenance cost outweighs the retrieval benefit for a memory system whose files are already reachable by path. @@ -30,14 +40,14 @@ Three layers, each answering one question: ```text memory (long-lived, low-churn) — "there is this thing" -anchor (bookmark, position) — "the content of that thing is here" -file (content host) — the actual bytes +tessera (bookmark, position) — "the content of that thing is here" +file (content host) — the actual bytes ``` The Agent does **not** need to remember which file, at which line, holds which content — that is high-churn, fragile knowledge. It needs to remember *that the thing exists* (memory) and have a cheap, objective way to reach *its content* -(anchor → file). The anchor is the bridge; it is an **external buffer layer**, +(tessera → file). The tessera is the bridge; it is an **external buffer layer**, not memory content and not a file-content replica. ## 3. Decisions @@ -47,44 +57,44 @@ not memory content and not a file-content replica. The file content source (full-text index over files, `.nmg-search-scope`, incremental crawler, file FTS) is **dropped** as a maintained feature. Files are not indexed, not crawled, not searched by NMG. They remain reachable through -anchors (and through the Agent's own `read`/`grep` tools, which NMG does not +tesserae (and through the Agent's own `read`/`grep` tools, which NMG does not replace). Rationale: maintaining a file index is expensive and its marginal value over -anchors + direct tool access is low. The file is the content host; NMG only +tesserae + direct tool access is low. The file is the content host; NMG only needs *pointers into it*. -### 3.2 Anchors are an independent, searchable source +### 3.2 Tesserae are an independent, searchable source -An **anchor** (a bookmark) is a first-class row, not a field glued onto a -memory. Anchors live in their own store and are **searched alongside memory** — -a single query returns memory hits *and* anchor hits. +A **tessera** (a bookmark) is a first-class row, not a field glued onto a +memory. Tesserae live in their own store and are **searched alongside memory** — +a single query returns memory hits *and* tessera hits. ```text nmg search "budget" - ├── memory source: FTS over memory_records (existing) - └── anchor source: FTS over anchors (label/path) (new) + ├── memory source: FTS over memory_records (existing) + └── tessera source: FTS over tesserae (label/snippet) (new) ``` -An anchor row carries: +A tessera row carries: -| field | meaning | -| --------- | ---------------------------------------------------------- | -| `path` | file the anchor points into (project-relative) | -| `snippet` | short content excerpt used for *relocation*, not line | -| `label` | Agent-written one-liner (searchable) | -| `kind` | e.g. `code`, `doc`, `note` (optional) | -| `memory_id` | optional back-pointer to the memory that raised it | +| field | meaning | +| ----------- | -------------------------------------------------------------- | +| `path` | file the tessera points into (project-relative) | +| `snippet` | short content excerpt used for *relocation*, not line | +| `label` | Agent-written one-liner (searchable) | +| `kind` | e.g. `code`, `doc`, `note` (optional) | +| `memory_id` | optional back-pointer to the memory that raised it | -Anchors are searchable independently: even with no matching memory (or after a -memory is superseded), a matching anchor is still found. +Tesserae are searchable independently: even with no matching memory (or after a +memory is superseded), a matching tessera is still found. -### 3.3 Anchors are content-anchored, not line-anchored +### 3.3 Tesserae are content-anchored, not line-anchored -An anchor stores a **content snippet**, never a line number. Line numbers drift -on every edit; content is relocatable. To resolve an anchor, locate its snippet +A tessera stores a **content snippet**, never a line number. Line numbers drift +on every edit; content is relocatable. To resolve a tessera, locate its snippet in the current file (exact match → position; fuzzy fallback → nearest match; -absent → anchor is stale). This is the established pattern from +absent → tessera is stale). This is the established pattern from [gptme hash-anchored editing](https://github.com/gptme/gptme/blob/ae707fc8233e77d4da97fc74f94db1eaff1e381a/gptme/tools/_anchored.py), [agentic-bookmarks self-healing anchors](https://github.com/super-mega-lab/agentic-bookmarks), and [haido `hash_at_link` drift detection](https://github.com/lebac-svg/haido/blob/HEAD/docs/DESIGN.md): @@ -92,50 +102,50 @@ never persist a position that the file can invalidate; persist content and relocate. Staleness is **objective**: resolve on read; if the snippet no longer exists, -report the anchor as stale (memory stays valid — only the position is gone). +report the tessera as stale (memory stays valid — only the position is gone). -### 3.4 Markers are the index pointer between memory and anchor +### 3.4 Markers are the index pointer between memory and tessera NMG memory already carries an open-string metadata channel — `MemoryMarker` (`kind` open, `attributes` key/value), used today by `board_origin` and -`retrieveHint`. The memory↔anchor link rides the same channel: +`retrieveHint`. The memory↔tessera link rides the same channel: ```jsonc markers: [{ - "kind": "anchor_ref", - "attributes": { "anchorId": "…" } + "kind": "tessera_ref", + "attributes": { "tesseraId": "…" } }] ``` -The marker is a *pointer*; the anchor row is the *content*. This keeps the +The marker is a *pointer*; the tessera row is the *content*. This keeps the schema untouched (no migration) and gives RAII for free: markers follow their memory through supersede/delete. ### 3.5 Writing is active; recall is active + passive - **Write (active):** when the Agent records a memory that refers to a file - location, it optionally supplies an anchor (`nmg remember … --anchor - path:label`, or a dedicated `nmg anchor` action). Writing memory is already an - active act; adding an anchor is the same act, one extra field. No observer, no - auto-extraction. + location, it optionally supplies a tessera (`nmg remember … --tessera + PATH::SNIPPET`, or a dedicated `nmg tessera` action). Writing memory is + already an active act; adding a tessera is the same act, one extra field. No + observer, no auto-extraction. - **Recall (active + passive):** active search queries both sources; passive - automatic recall can surface anchors alongside memory. The marker lets recall - walk memory → anchor → file position when needed. + automatic recall can surface tesserae alongside memory. The marker lets + recall walk memory → tessera → file position when needed. ## 4. Architecture ```text -┌─────────────── search ────────────────────────────────┐ -│ query → memory hits (existing) + anchor hits (new) │ -└───────────────────────────┬───────────────────────────┘ +┌─────────────── search ─────────────────────────────────┐ +│ query → memory hits (existing) + tessera hits (new) │ +└───────────────────────────┬────────────────────────────┘ │ ┌───────────────────┴───────────────────┐ ▼ ▼ -┌───────────────┐ ┌───────────────────┐ -│ memory_records│ │ anchors │ -│ (LTG/STG) │ marker anchor_ref │ (path, snippet, │ -│ │ ──────────────────► │ label, memory_id)│ -└───────────────┘ └─────────┬─────────┘ +┌───────────────┐ ┌────────────────────┐ +│ memory_records│ │ tesserae │ +│ (LTG/STG) │ marker tessera_ref│ (path, snippet, │ +│ │ ─────────────────► │ label, memory_id) │ +└───────────────┘ └─────────┬──────────┘ │ resolve snippet ▼ file (content host, @@ -144,12 +154,12 @@ memory through supersede/delete. ## 5. Boundaries -- Anchors are **not memory**: no LTG/STG semantics, no provenance/verification - on anchor content. The `memory_id` back-pointer is optional linkage, not +- Tesserae are **not memory**: no LTG/STG semantics, no provenance/verification + on tessera content. The `memory_id` back-pointer is optional linkage, not memory content. - Files are **not indexed**: NMG stores pointers, never file content. The "files are not memory" red line is preserved by construction. -- Anchors are **sparse and Agent-authored**: no parser, no crawler, no +- Tesserae are **sparse and Agent-authored**: no parser, no crawler, no tree-sitter, no mdast. Document "concept units" remain out of scope (no deterministic parser; see the superseded design's structured-unit note). @@ -159,17 +169,17 @@ memory through supersede/delete. full-text file index as a second search source. This design keeps its conclusions that are still true — files are not memory; scope discipline matters; separated presentation is sane — but **drops the file index itself** in -favor of sparse, Agent-authored anchors. The maintenance-heavy machinery +favor of sparse, Agent-authored tesserae. The maintenance-heavy machinery (`.nmg-search-scope`, incremental crawler, file FTS, scope observer) is not part of this design. ## 7. Open questions (deferred) -- Anchor store location (separate table in the memory DB vs project-local +- Tessera store location (separate table in the memory DB vs project-local file). TBD at implementation. - Snippet length and relocation tolerance (exact vs fuzzy). -- Whether anchors get a TTL or are retired by staleness only. -- Presentation: anchors shown as a third partition, or merged with memory. +- Whether tesserae get a TTL or are retired by staleness only. +- Presentation: tesserae shown as a third partition, or merged with memory. ## 8. Research basis (surveyed 2026-09-02) diff --git a/dsh/dsh-nmg/src/plugin/index.ts b/dsh/dsh-nmg/src/plugin/index.ts index 061d69c..7a0308a 100644 --- a/dsh/dsh-nmg/src/plugin/index.ts +++ b/dsh/dsh-nmg/src/plugin/index.ts @@ -1155,7 +1155,7 @@ export function apply(ctx: Context): () => void { residence: { type: 'string', enum: ['ltg', 'stg'], description: 'ltg (durable) or stg (session/task-local).' }, writeReason: { type: 'string', description: 'Durable-write justification.' }, scope: { type: 'object', additionalProperties: true, description: 'Applicability scope, e.g. {"project":"nmg"}.' }, - anchors: { type: 'array', maxItems: 10, items: { type: 'object', properties: { path: { type: 'string', description: 'Project-relative file path the bookmark points into.' }, snippet: { type: 'string', description: 'Exact short content excerpt from that file used for relocation.' }, label: { type: 'string', description: 'Optional one-line searchable label.' }, kind: { type: 'string', description: 'Optional kind: code | doc | note.' } }, required: ['path', 'snippet'] }, description: 'Optional file bookmarks (anchors): point this memory at file locations you actually saw. One bookmark per {path, snippet} — snippet is the relocation key, so copy it verbatim from the file.' }, + tesserae: { type: 'array', maxItems: 10, items: { type: 'object', properties: { path: { type: 'string', description: 'Project-relative file path the bookmark points into.' }, snippet: { type: 'string', description: 'Exact short content excerpt from that file used for relocation.' }, label: { type: 'string', description: 'Optional one-line searchable label.' }, kind: { type: 'string', description: 'Optional kind: code | doc | note.' } }, required: ['path', 'snippet'] }, description: 'Optional file bookmarks (tesserae): point this memory at file locations you actually saw. One bookmark per {path, snippet} — snippet is the relocation key, so copy it verbatim from the file.' }, }, required: [], }, @@ -1201,7 +1201,7 @@ export function apply(ctx: Context): () => void { return 'nmg_remember save requires statement and nodeName.' } const params: Record = { statement: args.statement, nodeName: args.nodeName, projectDir: workspaceRoot } - if (args.anchors && Array.isArray(args.anchors)) params.anchors = args.anchors + if (args.tesserae && Array.isArray(args.tesserae)) params.tesserae = args.tesserae if (args.memoryType) params.memoryType = args.memoryType if (args.recallTriggers) params.recallTriggers = args.recallTriggers if (args.stateKey) params.stateKey = args.stateKey diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 7da433a..f970ced 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -202,7 +202,7 @@ export const NMG_CLI_COMMANDS: readonly CliCommandSpec[] = [ "opened-at", "related-memory", "recall-trigger", - "anchor", + "tessera", ], flags: [], usageDetail: `Remember options: @@ -224,7 +224,7 @@ export const NMG_CLI_COMMANDS: readonly CliCommandSpec[] = [ --external-source REF External provenance: web:URL or file:PATH --retrieved-at ISO External retrieval timestamp (default: now) --content-hash HASH Optional external content hash - --anchor PATH::SNIPPET[::LABEL] Repeatable file bookmark: path, ::, content + --tessera PATH::SNIPPET[::LABEL] Repeatable file bookmark: path, ::, content snippet (relocation key), optional ::LABEL`, buildParams: rememberParams, }, @@ -1024,36 +1024,36 @@ function rememberParams(values: OptionValues): NmgRememberParams { openedAt: firstOption(values, "opened-at"), relatedMemoryIds: values.options.get("related-memory"), recallTriggers: values.options.get("recall-trigger"), - anchors: parseAnchorOptions(values.options.get("anchor")), + tesserae: parseTesseraOptions(values.options.get("tessera")), markers: externalMarker, projectDir: optionalResolvedPath(firstOption(values, "project-dir")), }) as unknown as NmgRememberParams; } -/** Parse repeatable `--anchor PATH::SNIPPET[:LABEL]` options into AnchorInput +/** Parse repeatable `--tessera PATH::SNIPPET[:LABEL]` options into TesseraInput * entries. The snippet is everything up to the next `::` (or the end); the * optional label follows a second `::`. Paths must not contain `::`. */ -function parseAnchorOptions( +function parseTesseraOptions( raw: unknown, ): Array<{ path: string; snippet: string; label?: string }> | undefined { const values = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]; - const anchors: Array<{ path: string; snippet: string; label?: string }> = []; + const tesserae: Array<{ path: string; snippet: string; label?: string }> = []; for (const value of values) { const text = String(value ?? ""); const parts = text.split("::"); if (parts.length < 2 || !parts[0]!.trim() || !parts[1]!.trim()) { throw new Error( - "--anchor must be PATH::SNIPPET (path then :: then content snippet), optionally ::LABEL", + "--tessera must be PATH::SNIPPET (path then :: then content snippet), optionally ::LABEL", ); } const path = parts[0]!.trim(); const snippet = parts[1]!.trim(); const label = parts.length > 2 ? parts.slice(2).join("::").trim() : undefined; - const anchor: { path: string; snippet: string; label?: string } = { path, snippet }; - if (label) anchor.label = label; - anchors.push(anchor); + const tessera: { path: string; snippet: string; label?: string } = { path, snippet }; + if (label) tessera.label = label; + tesserae.push(tessera); } - return anchors.length > 0 ? anchors : undefined; + return tesserae.length > 0 ? tesserae : undefined; } function resolutionParams( diff --git a/src/cli/main.ts b/src/cli/main.ts index 191dbea..231ac89 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -434,11 +434,11 @@ function parseOptions(args: readonly string[]): OptionValues { return { flags, options, positionals }; } -/** Append the FILES / ANCHORS source-section lines of a search result. Kept as +/** Append the FILES / TESSERAE source-section lines of a search result. Kept as * its own helper so humanResult's branching stays under the complexity gate. */ function sourceSectionLines( files: Array<{ path: string; excerpt: string }> | undefined, - anchors: Array<{ path: string; label: string; line?: number; stale?: boolean }> | undefined, + tesserae: Array<{ path: string; label: string; line?: number; stale?: boolean }> | undefined, ): string[] { const lines: string[] = []; if (files && files.length > 0) { @@ -447,11 +447,11 @@ function sourceSectionLines( lines.push(`${file.path}\t${file.excerpt}`); } } - if (anchors && anchors.length > 0) { - lines.push("ANCHORS:"); - for (const anchor of anchors) { - const position = anchor.stale ? "(stale)" : anchor.line ? `:${anchor.line}` : ""; - lines.push(`${anchor.path}${position}\t${anchor.label}`); + if (tesserae && tesserae.length > 0) { + lines.push("TESSERAE:"); + for (const tessera of tesserae) { + const position = tessera.stale ? "(stale)" : tessera.line ? `:${tessera.line}` : ""; + lines.push(`${tessera.path}${position}\t${tessera.label}`); } } return lines; @@ -605,14 +605,14 @@ function humanResult(value: unknown): string { node: { canonicalName: string }; }>; files?: Array<{ path: string; excerpt: string }>; - anchors?: Array<{ path: string; label: string; line?: number; stale?: boolean }>; + tesserae?: Array<{ path: string; label: string; line?: number; stale?: boolean }>; timings?: { timings?: Record; totalMs?: number }; }; const lines = context.results.map( ({ memory, node }) => `${memory.id}\t${memory.memoryType}\tL${memory.tier}\t${node.canonicalName}\t${memory.statement}`, ); - lines.push(...sourceSectionLines(context.files, context.anchors)); + lines.push(...sourceSectionLines(context.files, context.tesserae)); if (context.timings) { const sections = Object.entries(context.timings.timings ?? {}) .sort((left, right) => right[1] - left[1]) diff --git a/src/cli/protocol.ts b/src/cli/protocol.ts index d59ac62..7e0fda4 100644 --- a/src/cli/protocol.ts +++ b/src/cli/protocol.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import type { ActiveGraphBudget, - AnchorInput, + TesseraInput, ClaimOutcomeEvent, ClaimPosterior, MemoryActor, @@ -254,10 +254,10 @@ export interface NmgRememberParams { markers?: MemoryMarker[]; /** Short aliases or likely query phrases used only for recall routing. */ recallTriggers?: string[]; - /** Optional file anchors (bookmarks): path + content snippet pointing into a + /** Optional file tesserae (bookmarks): path + content snippet pointing into a * project file. Persisted with the memory; searchable as an independent * source. */ - anchors?: AnchorInput[]; + tesserae?: TesseraInput[]; projectDir?: string; } diff --git a/src/cli/service.ts b/src/cli/service.ts index ca9e764..0eeac3c 100644 --- a/src/cli/service.ts +++ b/src/cli/service.ts @@ -20,8 +20,8 @@ import { drainNodeSummaries, } from "../integration/node-summarizer.ts"; import type { - AnchorInput, - AnchorHit, + TesseraInput, + TesseraHit, LeafSummaryProvider, NodeSummaryProvider, RememberInput, @@ -1386,17 +1386,17 @@ export class NmgService { if (projectDir) { fileHits = this.#searchProjectFiles(projectDir, query, options.limit ?? 8); } - // Anchor (bookmark) source: independent of projectDir — anchors live in the + // Tessera (bookmark) source: independent of projectDir — tesserae live in the // shared store and are searched alongside memory. Snippet relocation to a // line needs the project root (to read the current file), so the resolver // is applied here, not in the store. - const anchorHits = this.#resolveAnchorLines( - this.#searchAnchorSource(query, options.limit ?? 8), + const tesseraHits = this.#resolveTesseraLines( + this.#searchTesseraSource(query, options.limit ?? 8), projectDir, ); const withFiles = (context: T): T => { if (fileHits.length > 0) context.files = fileHits; - if (anchorHits.length > 0) context.anchors = anchorHits; + if (tesseraHits.length > 0) context.tesserae = tesseraHits; return context; }; const runOne = async (store: NmgStore, raw: string): Promise => { @@ -1515,13 +1515,13 @@ export class NmgService { } } - /** Search the anchor (bookmark) source across stores. Anchor rows are in the + /** Search the tessera (bookmark) source across stores. Tessera rows are in the * store (independent of any project index), so this does not need a * projectDir; snippet relocation to a line is applied when a project root * is available at call time. */ - #searchAnchorSource(query: string, limit: number): AnchorHit[] { - const anchorLimit = Math.max(1, Math.min(limit, 10)); - const rows = this.#getStore().searchAnchors(query, anchorLimit); + #searchTesseraSource(query: string, limit: number): TesseraHit[] { + const tesseraLimit = Math.max(1, Math.min(limit, 10)); + const rows = this.#getStore().searchTesserae(query, tesseraLimit); return rows.map((row) => ({ id: row.id, path: row.path, @@ -1532,10 +1532,10 @@ export class NmgService { })); } - /** Resolve anchor snippets to current line numbers against a project root. + /** Resolve tessera snippets to current line numbers against a project root. * Best-effort: file missing/unreadable or snippet absent marks stale. */ - #resolveAnchorLines(anchors: AnchorHit[], projectRoot?: string): AnchorHit[] { - if (!projectRoot || anchors.length === 0) return anchors; + #resolveTesseraLines(tesserae: TesseraHit[], projectRoot?: string): TesseraHit[] { + if (!projectRoot || tesserae.length === 0) return tesserae; const cache = new Map(); // absPath -> lines | null const linesFor = (path: string): string[] | null => { const abs = resolve(projectRoot, path); @@ -1544,17 +1544,17 @@ export class NmgService { cache.set(abs, lines); return lines; }; - return anchors.map((anchor) => { - if (!anchor.snippet) return { ...anchor, stale: true }; - const lines = linesFor(anchor.path); - if (!lines) return { ...anchor, stale: true }; - const target = anchor.snippet.trim(); + return tesserae.map((tessera) => { + if (!tessera.snippet) return { ...tessera, stale: true }; + const lines = linesFor(tessera.path); + if (!lines) return { ...tessera, stale: true }; + const target = tessera.snippet.trim(); // Snippet is the relocation key: locate the line whose content contains // it (or that it contains, for short fragments). Best-effort single-line // relocation in the MVP. const found = lines.findIndex((line) => line.includes(target)); - if (found === -1) return { ...anchor, stale: true }; - return { ...anchor, line: found + 1 }; + if (found === -1) return { ...tessera, stale: true }; + return { ...tessera, line: found + 1 }; }); } @@ -1859,7 +1859,7 @@ function parseRememberParams(value: unknown): NmgRememberParams { sourceRef: optionalString(params, "sourceRef"), markers: optionalMarkers(params, "markers"), recallTriggers: optionalRecallTriggers(params), - anchors: optionalAnchors(params, "anchors"), + tesserae: optionalTesserae(params, "tesserae"), unsafe: optionalBoolean(params, "unsafe"), projectDir: optionalString(params, "projectDir"), }; @@ -1896,29 +1896,32 @@ function optionalEvidenceSource( }; } -/** Parse the optional anchors array on a remember write. Each anchor needs a +/** Parse the optional tesserae array on a remember write. Each tessera needs a * path and a snippet (the relocation key); label/kind are optional. */ -function optionalAnchors(params: Record, key: string): AnchorInput[] | undefined { +function optionalTesserae( + params: Record, + key: string, +): TesseraInput[] | undefined { const value = params[key]; if (value === undefined) return undefined; if (!Array.isArray(value) || value.length > 10) { - throw new NmgProtocolError("INVALID_PARAMS", `${key} must be an array of at most 10 anchors`); + throw new NmgProtocolError("INVALID_PARAMS", `${key} must be an array of at most 10 tesserae`); } return value.map((entry) => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { throw new NmgProtocolError("INVALID_PARAMS", `${key} entries must be objects`); } - const anchor = entry as Record; + const tessera = entry as Record; return { - path: requiredString(anchor, "path"), - snippet: requiredString(anchor, "snippet"), - label: optionalString(anchor, "label"), - kind: optionalString(anchor, "kind"), + path: requiredString(tessera, "path"), + snippet: requiredString(tessera, "snippet"), + label: optionalString(tessera, "label"), + kind: optionalString(tessera, "kind"), }; }); } -/** Read a file's lines, or null when unreadable/missing. Used by anchor +/** Read a file's lines, or null when unreadable/missing. Used by tessera * snippet relocation. */ function readLinesSafe(absPath: string): string[] | null { try { diff --git a/src/core/store/retrieval.ts b/src/core/store/retrieval.ts index 406fa37..d3caf93 100644 --- a/src/core/store/retrieval.ts +++ b/src/core/store/retrieval.ts @@ -54,7 +54,7 @@ import type { ActiveGraphBudget, ActiveGraphBudgetUsage, ActiveGraphSelection, - AnchorRecord, + TesseraRecord, LeafBlock, HistoryRecord, MemoryContext, @@ -1641,20 +1641,20 @@ export function withRetrieval(Base: TBase) { ); } - /** Search the anchor (bookmark) source independently of memory. Matches on - * the agent-written label via FTS. Anchors are content-anchored file + /** Search the tessera (bookmark) source independently of memory. Matches on + * the agent-written label via FTS. Tesserae are content-anchored file * pointers — the snippet relocation to a line happens in the service layer * (which knows the project root); the store only returns raw rows. */ - searchAnchors(query: string, limit = 8): AnchorRecord[] { + searchTesserae(query: string, limit = 8): TesseraRecord[] { const expression = ftsExpression(query); if (!expression) return []; const rows = this.db .prepare( `SELECT a.id, a.path, a.snippet, a.label, a.kind, a.memory_id, a.created_at - FROM anchors_fts f - JOIN anchors a ON a.rowid = f.rowid - WHERE anchors_fts MATCH ? - ORDER BY bm25(anchors_fts) + FROM tesserae_fts f + JOIN tesserae a ON a.rowid = f.rowid + WHERE tesserae_fts MATCH ? + ORDER BY bm25(tesserae_fts) LIMIT ?`, ) .all(expression, Math.max(1, Math.min(limit, 50))) as Array<{ @@ -1677,14 +1677,14 @@ export function withRetrieval(Base: TBase) { })); } - /** Load anchors by id (used when resolving anchor_ref markers on a memory). */ - getAnchorsByIds(ids: readonly string[]): AnchorRecord[] { + /** Load tesserae by id (used when resolving tessera_ref markers on a memory). */ + getTesseraeByIds(ids: readonly string[]): TesseraRecord[] { if (ids.length === 0) return []; const placeholders = ids.map(() => "?").join(","); const rows = this.db .prepare( `SELECT id, path, snippet, label, kind, memory_id, created_at - FROM anchors WHERE id IN (${placeholders})`, + FROM tesserae WHERE id IN (${placeholders})`, ) .all(...ids) as Array<{ id: string; diff --git a/src/core/store/schema.ts b/src/core/store/schema.ts index 2ee4734..4057dbb 100644 --- a/src/core/store/schema.ts +++ b/src/core/store/schema.ts @@ -19,7 +19,39 @@ import { encodeVector, parseVector } from "./vector-codec.ts"; type Row = Record; +/** + * Forward-migrate stores created before the anchors → tesserae rename (PR #19): + * the old `anchors` table becomes `tesserae` in place so existing bookmarks and + * their markers survive. Runs before the main idempotent DDL so the later + * `CREATE TABLE IF NOT EXISTS tesserae` is a no-op against the renamed table. + * FTS index and triggers are rebuilt under the new names; the old FTS5 + * external-content table is dropped because FTS5 content tables cannot be + * renamed reliably. Existing `anchor_ref` markers are rewritten to + * `tessera_ref` in place (the open-string markers channel, no schema change). + */ +export function migrateLegacyTesseraeTables(db: DatabaseSync): void { + const tables = new Set( + (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Row[]).map((row) => + String(row.name), + ), + ); + if (tables.has("tesserae") || !tables.has("anchors")) return; + db.exec(` + DROP TRIGGER IF EXISTS anchors_fts_ai; + DROP TRIGGER IF EXISTS anchors_fts_ad; + DROP TRIGGER IF EXISTS anchors_fts_au; + DROP TABLE IF EXISTS anchors_fts; + ALTER TABLE anchors RENAME TO tesserae; + `); + db.exec(` + UPDATE memory_records + SET markers_json = REPLACE(markers_json, '"anchor_ref"', '"tessera_ref"') + WHERE markers_json LIKE '%anchor_ref%'; + `); +} + export function migrate(db: DatabaseSync): void { + migrateLegacyTesseraeTables(db); db.exec(` CREATE TABLE IF NOT EXISTS history_records ( id TEXT PRIMARY KEY, @@ -507,13 +539,13 @@ export function migrate(db: DatabaseSync): void { counter INTEGER NOT NULL DEFAULT 0 ); - -- Memory anchors (bookmarks): content-anchored file locations a memory + -- Memory tesserae (bookmarks): content-anchored file locations a memory -- points into. Independent searchable source (label/path/snippet FTS); - -- memory ↔ anchor linkage rides the open-string markers channel - -- (ANCHOR_REF_MARKER), so no schema coupling to memory_records is needed + -- memory ↔ tessera linkage rides the open-string markers channel + -- (TESSERA_REF_MARKER), so no schema coupling to memory_records is needed -- here beyond an optional soft memory_id back-pointer. Snippet is the -- relocation key — line numbers are never persisted (they drift on edit). - CREATE TABLE IF NOT EXISTS anchors ( + CREATE TABLE IF NOT EXISTS tesserae ( id TEXT PRIMARY KEY, path TEXT NOT NULL, snippet TEXT NOT NULL, @@ -522,27 +554,27 @@ export function migrate(db: DatabaseSync): void { memory_id TEXT, created_at TEXT NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_anchors_path ON anchors(path); - CREATE INDEX IF NOT EXISTS idx_anchors_memory ON anchors(memory_id) WHERE memory_id IS NOT NULL; - CREATE VIRTUAL TABLE IF NOT EXISTS anchors_fts USING fts5( + CREATE INDEX IF NOT EXISTS idx_tesserae_path ON tesserae(path); + CREATE INDEX IF NOT EXISTS idx_tesserae_memory ON tesserae(memory_id) WHERE memory_id IS NOT NULL; + CREATE VIRTUAL TABLE IF NOT EXISTS tesserae_fts USING fts5( label, snippet, path UNINDEXED, - content = 'anchors', + content = 'tesserae', content_rowid = 'rowid', tokenize = 'unicode61' ); - CREATE TRIGGER IF NOT EXISTS anchors_fts_ai AFTER INSERT ON anchors BEGIN - INSERT INTO anchors_fts (rowid, label, snippet, path) VALUES (new.rowid, new.label, new.snippet, new.path); + CREATE TRIGGER IF NOT EXISTS tesserae_fts_ai AFTER INSERT ON tesserae BEGIN + INSERT INTO tesserae_fts (rowid, label, snippet, path) VALUES (new.rowid, new.label, new.snippet, new.path); END; - CREATE TRIGGER IF NOT EXISTS anchors_fts_ad AFTER DELETE ON anchors BEGIN - INSERT INTO anchors_fts (anchors_fts, rowid, label, snippet, path) + CREATE TRIGGER IF NOT EXISTS tesserae_fts_ad AFTER DELETE ON tesserae BEGIN + INSERT INTO tesserae_fts (tesserae_fts, rowid, label, snippet, path) VALUES ('delete', old.rowid, old.label, old.snippet, old.path); END; - CREATE TRIGGER IF NOT EXISTS anchors_fts_au AFTER UPDATE ON anchors BEGIN - INSERT INTO anchors_fts (anchors_fts, rowid, label, snippet, path) + CREATE TRIGGER IF NOT EXISTS tesserae_fts_au AFTER UPDATE ON tesserae BEGIN + INSERT INTO tesserae_fts (tesserae_fts, rowid, label, snippet, path) VALUES ('delete', old.rowid, old.label, old.snippet, old.path); - INSERT INTO anchors_fts (rowid, label, snippet, path) VALUES (new.rowid, new.label, new.snippet, new.path); + INSERT INTO tesserae_fts (rowid, label, snippet, path) VALUES (new.rowid, new.label, new.snippet, new.path); END; CREATE INDEX IF NOT EXISTS idx_memory_records_node_tier diff --git a/src/core/store/writes.ts b/src/core/store/writes.ts index 5af7a22..9c2c8f4 100644 --- a/src/core/store/writes.ts +++ b/src/core/store/writes.ts @@ -64,7 +64,7 @@ import { assertTemporalValidity } from "../semantic-domain.ts"; import { recallTriggerMarkers } from "../recall-triggers.ts"; import { type ScopeWriteIndexRow, writeTokens } from "./scope-write-index.ts"; -/** Merge caller markers with auto-generated anchor_ref markers. Kept as a +/** Merge caller markers with auto-generated tessera_ref markers. Kept as a * module-level function so rememberInner's cyclomatic complexity stays flat. */ function mergeMarkers( base: readonly MemoryMarker[] | undefined, @@ -501,12 +501,12 @@ export function withWrites(Base: TBase) { : undefined; const supersedesId = input.supersedesId ?? (automaticPrevious ? String(automaticPrevious.id) : undefined); - // Memory anchors: agent-supplied bookmarks attached to this write. - // Anchor ids are pre-generated so the anchor_ref markers can ride the + // Memory tesserae: agent-supplied bookmarks attached to this write. + // Tessera ids are pre-generated so the tessera_ref markers can ride the // same memory write (no second transaction); rows land after addMemory // so they can carry the memory id. Content-anchored — snippet only, // never a line number. - const { anchorPlan, anchorMarkers } = this.#planAnchorWrites(input); + const { tesseraPlan, tesseraMarkers } = this.#planTesseraWrites(input); let supersededNodeId: string | undefined; if (supersedesId) { const previous = this.db @@ -551,7 +551,7 @@ export function withWrites(Base: TBase) { predicateKey: input.predicateKey, extractMethod: input.extractMethod, claims: input.claims, - markers: mergeMarkers(input.markers, anchorMarkers), + markers: mergeMarkers(input.markers, tesseraMarkers), recallTriggers: input.recallTriggers, tier: input.tier, importance: input.importance, @@ -584,8 +584,8 @@ export function withWrites(Base: TBase) { if (supersededNodeId && supersededNodeId !== node.id) { this.refreshNodeResidence(supersededNodeId, memory.createdAt); } - // Land anchor rows inside the same transaction as the memory write. - this.#insertAnchorRows(anchorPlan, memory.id); + // Land tessera rows inside the same transaction as the memory write. + this.#insertTesseraRows(tesseraPlan, memory.id); if (manageTransaction) this.db.exec("COMMIT"); const written = { history, node, memory }; return { @@ -600,46 +600,46 @@ export function withWrites(Base: TBase) { } } - /** Pre-generate anchor rows and their anchor_ref markers for a memory write. - * Invalid anchors (missing path or snippet) are skipped. */ - #planAnchorWrites(input: RememberInput): { - anchorPlan: Array<{ + /** Pre-generate tessera rows and their tessera_ref markers for a memory write. + * Invalid tesserae (missing path or snippet) are skipped. */ + #planTesseraWrites(input: RememberInput): { + tesseraPlan: Array<{ id: string; path: string; snippet: string; label: string; kind?: string; }>; - anchorMarkers: MemoryMarker[]; + tesseraMarkers: MemoryMarker[]; } { - const anchorPlan: Array<{ + const tesseraPlan: Array<{ id: string; path: string; snippet: string; label: string; kind?: string; }> = []; - const anchorMarkers: MemoryMarker[] = []; - for (const anchor of input.anchors ?? []) { - const path = String(anchor.path ?? "").trim(); - const snippet = String(anchor.snippet ?? "").trim(); - if (!path || !snippet) continue; // anchors need a file + content to relocate + const tesseraMarkers: MemoryMarker[] = []; + for (const tessera of input.tesserae ?? []) { + const path = String(tessera.path ?? "").trim(); + const snippet = String(tessera.snippet ?? "").trim(); + if (!path || !snippet) continue; // tesserae need a file + content to relocate const id = randomUUID(); - anchorPlan.push({ + tesseraPlan.push({ id, path, snippet, - label: String(anchor.label ?? "").trim(), - kind: anchor.kind?.trim() || undefined, + label: String(tessera.label ?? "").trim(), + kind: tessera.kind?.trim() || undefined, }); - anchorMarkers.push({ kind: "anchor_ref", attributes: { anchorId: id, path } }); + tesseraMarkers.push({ kind: "tessera_ref", attributes: { tesseraId: id, path } }); } - return { anchorPlan, anchorMarkers }; + return { tesseraPlan, tesseraMarkers }; } - /** Insert planned anchor rows inside the memory-write transaction. */ - #insertAnchorRows( - anchorPlan: Array<{ + /** Insert planned tessera rows inside the memory-write transaction. */ + #insertTesseraRows( + tesseraPlan: Array<{ id: string; path: string; snippet: string; @@ -648,14 +648,14 @@ export function withWrites(Base: TBase) { }>, memoryId: string, ): void { - if (anchorPlan.length === 0) return; - const insertAnchor = this.db.prepare( - `INSERT INTO anchors (id, path, snippet, label, kind, memory_id, created_at) + if (tesseraPlan.length === 0) return; + const insertTessera = this.db.prepare( + `INSERT INTO tesserae (id, path, snippet, label, kind, memory_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ); const createdAt = new Date().toISOString(); - for (const plan of anchorPlan) { - insertAnchor.run( + for (const plan of tesseraPlan) { + insertTessera.run( plan.id, plan.path, plan.snippet, diff --git a/src/core/types.ts b/src/core/types.ts index 77ec160..1a2b9c1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -376,10 +376,10 @@ export interface RememberInput { expiresAt?: string; writeReason?: string; writeSource?: MemoryWriteSource; - /** Optional file anchors (bookmarks) attached to this memory. Each anchor + /** Optional file tesserae (bookmarks) attached to this memory. Each tessera * points into a project file (path + content snippet). Writing is active — - * the Agent supplies anchors when the memory refers to a file location. */ - anchors?: AnchorInput[]; + * the Agent supplies tesserae when the memory refers to a file location. */ + tesserae?: TesseraInput[]; /** Disable per-phase timing for this write (default: enabled). */ perf?: boolean; } @@ -853,12 +853,12 @@ export interface FileHit { score: number; } -/** A file location a memory points into (a bookmark). Content-anchored: the +/** A file location a memory points into (a tessera / bookmark). Content-anchored: the * snippet relocates on read, so no line number is ever persisted. */ -export interface AnchorRecord { - /** Stable anchor id (independent of any memory id). */ +export interface TesseraRecord { + /** Stable tessera id (independent of any memory id). */ id: string; - /** Project-relative file path the anchor points into. */ + /** Project-relative file path the tessera points into. */ path: string; /** Short content excerpt used for relocation, never a line number. */ snippet: string; @@ -866,39 +866,39 @@ export interface AnchorRecord { label: string; /** Optional kind, e.g. "code" | "doc" | "note". */ kind?: string; - /** Owning memory id, when the anchor was raised by a memory write. */ + /** Owning memory id, when the tessera was raised by a memory write. */ memoryId?: string; createdAt: string; } -/** Anchor input on a memory write (agent-supplied, active). */ -export interface AnchorInput { +/** Tessera input on a memory write (agent-supplied, active). */ +export interface TesseraInput { path: string; snippet: string; label?: string; kind?: string; } -/** Anchor hit surfaced by search — an anchor row plus its resolved line, or a +/** Tessera hit surfaced by search — a tessera row plus its resolved line, or a * staleness marker when the snippet no longer exists in the file. */ -export interface AnchorHit { +export interface TesseraHit { id: string; path: string; label: string; kind?: string; memoryId?: string; - /** The content snippet this anchor relocates by (also shown as excerpt). */ + /** The content snippet this tessera relocates by (also shown as excerpt). */ snippet?: string; /** Resolved line in the current file (1-based), when the snippet was found. */ line?: number; - /** True when the snippet no longer exists in the file (stale anchor). */ + /** True when the snippet no longer exists in the file (stale tessera). */ stale?: boolean; /** Relevance score when matched via FTS; higher is more relevant. */ score?: number; } -/** Marker kind linking a memory to one of its anchors (memory ↔ anchor). */ -export const ANCHOR_REF_MARKER = "anchor_ref"; +/** Marker kind linking a memory to one of its tesserae (memory ↔ tessera). */ +export const TESSERA_REF_MARKER = "tessera_ref"; export interface MemoryContext { results: MemorySearchResult[]; @@ -907,10 +907,10 @@ export interface MemoryContext { * provenance/scope/verification and never enter STG/LTG. Present only when * the file content source is enabled for the searched project. */ files?: FileHit[]; - /** Anchor (bookmark) hits, an independent searchable source alongside + /** Tessera (bookmark) hits, an independent searchable source alongside * memory. Each hit carries the resolved line in the current file, or a * staleness marker when the snippet no longer exists. */ - anchors?: AnchorHit[]; + tesserae?: TesseraHit[]; /** Chain edges (DAG) collected during expandChains: the directed edges of * every chain surfaced. Lets the presentation layer render a chain as a * DAG (branching `A --> B & C`) instead of a linear position sequence. diff --git a/src/integration/agent-surface.ts b/src/integration/agent-surface.ts index 54fd80d..6603f69 100644 --- a/src/integration/agent-surface.ts +++ b/src/integration/agent-surface.ts @@ -117,11 +117,11 @@ export function renderCompactSearchSurface( const fileLines = (context.files ?? []).map( (hit) => `- file=${hit.path}; excerpt=${hit.excerpt}`, ); - const anchorLines = (context.anchors ?? []).map((anchor) => { - const position = anchor.stale ? "(stale)" : anchor.line ? `:${anchor.line}` : ""; - return `- anchor=${anchor.path}${position}; label=${anchor.label || "(bookmark)"}`; + const tesseraLines = (context.tesserae ?? []).map((tessera) => { + const position = tessera.stale ? "(stale)" : tessera.line ? `:${tessera.line}` : ""; + return `- tessera=${tessera.path}${position}; label=${tessera.label || "(bookmark)"}`; }); - if (context.candidates.length === 0 && fileLines.length === 0 && anchorLines.length === 0) { + if (context.candidates.length === 0 && fileLines.length === 0 && tesseraLines.length === 0) { return options.emptyText ?? "No matching NMG memory found."; } const lines = context.candidates.map((candidate) => { @@ -142,7 +142,7 @@ export function renderCompactSearchSurface( options.preamble, ...lines, ...fileLines, - ...anchorLines, + ...tesseraLines, context.logicalChainCount > 0 ? `logical_chains=${context.logicalChainCount}; use nmg_get for compact chain structure with exact evidence.` : "", diff --git a/src/integration/search-projection.ts b/src/integration/search-projection.ts index df34980..612c0e6 100644 --- a/src/integration/search-projection.ts +++ b/src/integration/search-projection.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import type { AnchorHit, FileHit, MemoryContext, MemorySearchResult } from "../core/types.ts"; +import type { TesseraHit, FileHit, MemoryContext, MemorySearchResult } from "../core/types.ts"; import type { SessionDisclosureLevel } from "../core/session-active-graph.ts"; import { logicalChainCount, logicalChainNames } from "./chain-projection.ts"; @@ -37,9 +37,9 @@ export interface CompactSearchContext { /** File-content source hits (bounded passive-scope FTS), separate from * memory candidates. Present when the file source is enabled. */ files?: FileHit[]; - /** Anchor (bookmark) hits — file locations attached to memories, resolved to + /** Tessera (bookmark) hits — file locations attached to memories, resolved to * lines (or marked stale) by the service layer. */ - anchors?: AnchorHit[]; + tesserae?: TesseraHit[]; } /** Agent-facing search projection. Exact records and evidence remain behind `nmg get`. */ @@ -61,7 +61,7 @@ export function compactSearchContext(context: MemoryContext): CompactSearchConte activeGraphId: context.activeGraph?.id ?? null, deferredMemoryIds: context.progressiveDisclosure?.deferredMemoryIds ?? [], ...(context.files && context.files.length > 0 ? { files: context.files } : {}), - ...(context.anchors && context.anchors.length > 0 ? { anchors: context.anchors } : {}), + ...(context.tesserae && context.tesserae.length > 0 ? { tesserae: context.tesserae } : {}), }; } diff --git a/tests/core/anchors.test.ts b/tests/core/tesserae.test.ts similarity index 55% rename from tests/core/anchors.test.ts rename to tests/core/tesserae.test.ts index db7952e..6d95870 100644 --- a/tests/core/anchors.test.ts +++ b/tests/core/tesserae.test.ts @@ -5,10 +5,10 @@ import { join } from "node:path"; import test from "node:test"; import { NmgStore } from "../../src/core/store.ts"; -import { ANCHOR_REF_MARKER } from "../../src/core/types.ts"; +import { TESSERA_REF_MARKER } from "../../src/core/types.ts"; function withStore(run: (store: NmgStore) => void): void { - const directory = mkdtempSync(join(tmpdir(), "nmg-anchors-")); + const directory = mkdtempSync(join(tmpdir(), "nmg-tesserae-")); const store = new NmgStore(join(directory, "nmg.sqlite")); try { run(store); @@ -18,17 +18,17 @@ function withStore(run: (store: NmgStore) => void): void { } } -test("remember with anchors writes rows and anchor_ref markers", () => { +test("remember with tesserae writes rows and tessera_ref markers", () => { withStore((store) => { const result = store.remember({ - statement: "anchors are an independent searchable source", - nodeName: "anchor-test", + statement: "tesserae are an independent searchable source", + nodeName: "tessera-test", scope: { project: "smoke" }, - anchors: [ + tesserae: [ { path: "src/core/types.ts", - snippet: "export interface AnchorRecord", - label: "AnchorRecord type", + snippet: "export interface TesseraRecord", + label: "TesseraRecord type", kind: "code", }, { path: "docs/design.md", snippet: "## 3. Decisions", label: "design section" }, @@ -36,27 +36,27 @@ test("remember with anchors writes rows and anchor_ref markers", () => { }); const memory = store.getMemory(result.memory.id); assert.ok(memory, "memory exists"); - const refs = (memory.markers ?? []).filter((marker) => marker.kind === ANCHOR_REF_MARKER); - assert.equal(refs.length, 2, "one anchor_ref marker per anchor"); - // Each marker carries the anchor id; rows are findable by id. - const ids = refs.map((marker) => String(marker.attributes?.anchorId)); + const refs = (memory.markers ?? []).filter((marker) => marker.kind === TESSERA_REF_MARKER); + assert.equal(refs.length, 2, "one tessera_ref marker per tessera"); + // Each marker carries the tessera id; rows are findable by id. + const ids = refs.map((marker) => String(marker.attributes?.tesseraId)); assert.equal(ids.length, 2); - const byId = store.getAnchorsByIds(ids); + const byId = store.getTesseraeByIds(ids); assert.equal(byId.length, 2); - const byPath = byId.find((anchor) => anchor.path === "src/core/types.ts"); - assert.ok(byPath, "anchor row carries the path"); - assert.equal(byPath?.label, "AnchorRecord type"); + const byPath = byId.find((tessera) => tessera.path === "src/core/types.ts"); + assert.ok(byPath, "tessera row carries the path"); + assert.equal(byPath?.label, "TesseraRecord type"); assert.equal(byPath?.memoryId, result.memory.id); }); }); -test("searchAnchors matches label and snippet via FTS", () => { +test("searchTesserae matches label and snippet via FTS", () => { withStore((store) => { store.remember({ statement: "budget mechanism exists", - nodeName: "anchor-budget", + nodeName: "tessera-budget", scope: { project: "smoke" }, - anchors: [ + tesserae: [ { path: "docs/design/design.md", snippet: "unified session budget", @@ -64,40 +64,40 @@ test("searchAnchors matches label and snippet via FTS", () => { }, ], }); - const byLabel = store.searchAnchors("budget section", 5); + const byLabel = store.searchTesserae("budget section", 5); assert.equal(byLabel.length, 1, "label term hits"); assert.equal(byLabel[0]!.path, "docs/design/design.md"); - const bySnippet = store.searchAnchors("session budget", 5); + const bySnippet = store.searchTesserae("session budget", 5); assert.equal(bySnippet.length, 1, "snippet term hits"); }); }); -test("remember without anchors writes no rows and no markers", () => { +test("remember without tesserae writes no rows and no markers", () => { withStore((store) => { const result = store.remember({ - statement: "plain memory without anchors", - nodeName: "anchor-none", + statement: "plain memory without tesserae", + nodeName: "tessera-none", scope: { project: "smoke" }, }); const memory = store.getMemory(result.memory.id); assert.ok(memory); - const refs = (memory.markers ?? []).filter((marker) => marker.kind === ANCHOR_REF_MARKER); + const refs = (memory.markers ?? []).filter((marker) => marker.kind === TESSERA_REF_MARKER); assert.equal(refs.length, 0); - assert.equal(store.searchAnchors("plain memory", 5).length, 0); + assert.equal(store.searchTesserae("plain memory", 5).length, 0); }); }); -test("duplicate memory write does not duplicate anchors", () => { +test("duplicate memory write does not duplicate tesserae", () => { withStore((store) => { const input = { - statement: "duplicate anchor memory", - nodeName: "anchor-dup", + statement: "duplicate tessera memory", + nodeName: "tessera-dup", scope: { project: "smoke" }, - anchors: [{ path: "a.ts", snippet: "export const x", label: "a" }], + tesserae: [{ path: "a.ts", snippet: "export const x", label: "a" }], }; const first = store.remember(input); const second = store.remember(input); assert.equal(first.memory.id, second.memory.id, "exact duplicate returns existing"); - assert.equal(store.searchAnchors("export const x", 10).length, 1, "one anchor row only"); + assert.equal(store.searchTesserae("export const x", 10).length, 1, "one tessera row only"); }); }); From 7587da7c2040c10a8506e7d21c2d512a271af47d Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:02:40 +0800 Subject: [PATCH 2/9] chore: exclude auto-generated artifacts from version control All finally-built outputs that are reproducible from source should not be tracked; they regenerate on build and tracking them keeps the tree permanently dirty with drift-prone copies. - .nmg-search-scope: runtime hot-zone manifest (absolute paths, per-machine) - dsh/dsh-nmg/lib/: tsdown build output of the DSH host plugin (src + tsdown config remain; lib regenerates via npm run build in dsh/dsh-nmg) - src/prompts/nmg-prompts.generated.ts: emitted by generate-prompts.ts from nmg-prompts.yaml during npm run build / prompts:generate - dsh/dsh-nmg/package-lock.json: npm install side effect in a linked subpackage package-lock.json at the repository root stays tracked (lockfile for the root package); this commit also syncs its root-package bin entry with package.json (nmg-rcp), which npm install had left stale. --- .gitignore | 8 + .nmg-search-scope | 2 - dsh/dsh-nmg/lib/client.js | 198 --- dsh/dsh-nmg/lib/index.js | 1929 -------------------------- package-lock.json | 3 +- src/prompts/nmg-prompts.generated.ts | 117 -- 6 files changed, 10 insertions(+), 2247 deletions(-) delete mode 100644 .nmg-search-scope delete mode 100644 dsh/dsh-nmg/lib/client.js delete mode 100644 dsh/dsh-nmg/lib/index.js delete mode 100644 src/prompts/nmg-prompts.generated.ts diff --git a/.gitignore b/.gitignore index ce276d1..75318c7 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,14 @@ evals/snapshots/ *.sqlite-wal .wrangler/ +# File-content hot-zone manifest (runtime state; contains absolute paths) +/.nmg-search-scope + +# Generated build artifacts (regenerated by npm run build / dsh-nmg tsdown) +/src/prompts/nmg-prompts.generated.ts +dsh/dsh-nmg/lib/ +dsh/dsh-nmg/package-lock.json + # Large/generated binary artifacts (should never be tracked) *.sqlite *.safetensors diff --git a/.nmg-search-scope b/.nmg-search-scope deleted file mode 100644 index 4b15808..0000000 --- a/.nmg-search-scope +++ /dev/null @@ -1,2 +0,0 @@ -src/core -src/integration diff --git a/dsh/dsh-nmg/lib/client.js b/dsh/dsh-nmg/lib/client.js deleted file mode 100644 index e86d081..0000000 --- a/dsh/dsh-nmg/lib/client.js +++ /dev/null @@ -1,198 +0,0 @@ -window.__ModuleLoader__.load({id:`@nmg/dsh-nmg`,factory:e=>{var t={exports:{}},n=t.exports;Object.defineProperty(n,Symbol.toStringTag,{value:`Module`});var r=Object.create,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyNames,s=Object.getPrototypeOf,c=Object.prototype.hasOwnProperty,l=(e,t,n,r)=>{if(t&&typeof t==`object`||typeof t==`function`)for(var s=o(t),l=0,u=s.length,d;lt[e]).bind(null,d),enumerable:!(r=a(t,d))||r.enumerable});return e},u=(e,t,n)=>(n=e==null?{}:r(s(e)),l(t||!e||!e.__esModule||!c.call(e,`default`)?i(n,`default`,{value:e,enumerable:!0}):n,e));let d=e("react");d=u(d,1);let f=`nmg-toolview-css`;function p(){let e=!1;return(()=>{if(e||typeof document>`u`||document.querySelector(`style[data-plugin-css=`+JSON.stringify(f)+`]`))return;let t=document.createElement(`style`);t.dataset.plugin=`@nmg/dsh-nmg`,t.dataset.pluginCss=f,t.textContent=` - .nmg-tool-card { - display: block; - margin: 6px 0; - padding: 8px 12px; - border: 1px solid var(--nmg-border, rgba(127,127,127,.35)); - border-left: 3px solid var(--nmg-accent, #2563eb); - border-radius: 8px; - background: var(--nmg-surface, rgba(0,0,0,.04)); - color: var(--nmg-text, #111827); - font-size: 12px; - line-height: 1.5; - min-width: 0; - contain: content; - } - .nmg-tool-card-head { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 4px; - } - .nmg-tool-badge { - display: inline-block; - padding: 1px 6px; - border-radius: 4px; - background: var(--nmg-accent, #2563eb); - color: #ffffff; - font-size: 10px; - font-weight: 700; - letter-spacing: .5px; - line-height: 1.4; - } - .nmg-tool-name { - font-weight: 600; - font-family: monospace; - color: var(--nmg-text, #111827); - } - .nmg-tool-state { - margin-left: auto; - font-size: 10px; - text-transform: uppercase; - opacity: .75; - color: var(--nmg-text-dim, #6b7280); - } - .nmg-tool-label { - font-weight: 500; - color: var(--nmg-text, #111827); - margin-bottom: 4px; - word-break: break-word; - } - .nmg-tool-result { - margin: 0; - padding: 6px 8px; - border-radius: 6px; - background: var(--nmg-surface-2, rgba(0,0,0,.05)); - color: var(--nmg-text-2, #374151); - font-family: monospace; - font-size: 11px; - white-space: pre-wrap; - word-break: break-word; - max-height: 180px; - overflow: auto; - } - .nmg-tool-running { - font-size: 11px; - opacity: .6; - color: var(--nmg-text-dim, #6b7280); - } - .nmg-tool-error .nmg-tool-label { - color: #dc2626; - } - .nmg-recall-pill { - position: fixed; - z-index: 9999; - min-width: 0; - max-width: 92vw; - pointer-events: auto; /* shell.overlay is click-through; the pill opts back in */ - border: 1px solid var(--nmg-border, rgba(127,127,127,.35)); - border-left: 3px solid var(--nmg-accent, #2563eb); - border-radius: 10px; - background: var(--nmg-surface, rgba(0,0,0,.9)); - color: var(--nmg-text, #111827); - font-size: 12px; - line-height: 1.4; - box-shadow: 0 4px 16px rgba(0,0,0,.28); - overflow: hidden; - user-select: none; - touch-action: none; - } - /* collapsed: no fixed layout, sized by inline width:auto → wraps its content */ - .nmg-recall-pill-collapsed { - width: fit-content; - } - /* expanded: a proper window filling its inline width/height; body scrolls */ - .nmg-recall-pill-expanded { - display: flex; - flex-direction: column; - } - .nmg-recall-pill-expanded .nmg-recall-pill-body { - flex: 1; - overflow: auto; - cursor: text; - user-select: text; - } - .nmg-recall-pill-head { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 8px; - cursor: move; - flex: none; - } - .nmg-recall-pill .nmg-tool-badge { - flex: none; - } - .nmg-recall-dock-state { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--nmg-text, #111827); - } - .nmg-recall-pill-toggle, - .nmg-recall-pill-close { - flex: none; - border: none; - background: transparent; - color: var(--nmg-text-dim, #6b7280); - font-size: 12px; - cursor: pointer; - padding: 0 3px; - line-height: 1; - } - .nmg-recall-pill-toggle:hover, - .nmg-recall-pill-close:hover { - color: var(--nmg-text, #111827); - } - .nmg-recall-pill-body { - padding: 6px 10px 8px; - border-top: 1px solid var(--nmg-border, rgba(127,127,127,.25)); - cursor: text; - user-select: text; - } - .nmg-recall-pill-meta { - font-family: monospace; - font-size: 10px; - color: var(--nmg-text-dim, #6b7280); - word-break: break-all; - margin-bottom: 3px; - } - .nmg-recall-pill-preview { - color: var(--nmg-text-2, #374151); - word-break: break-word; - } - .nmg-recall-pill-card { - padding: 4px 0; - border-bottom: 1px solid var(--nmg-border, rgba(127,127,127,.18)); - } - .nmg-recall-pill-card:last-of-type { - border-bottom: none; - } - .nmg-recall-pill-nav { - display: flex; - align-items: center; - gap: 8px; - margin-top: 6px; - } - .nmg-recall-pill-navbtn { - border: 1px solid var(--nmg-border, rgba(127,127,127,.35)); - background: transparent; - color: var(--nmg-text, #111827); - border-radius: 6px; - font-size: 11px; - padding: 2px 8px; - cursor: pointer; - } - .nmg-recall-pill-navbtn:disabled { - opacity: .4; - cursor: default; - } - .nmg-recall-pill-navbtn:hover:not(:disabled) { - background: var(--nmg-surface-2, rgba(0,0,0,.06)); - } - .nmg-recall-pill-resize { - position: absolute; - right: 2px; - bottom: 2px; - width: 14px; - height: 14px; - cursor: nwse-resize; - opacity: .5; - background: linear-gradient(135deg, transparent 0 60%, var(--nmg-text-dim, #6b7280) 60% 75%, transparent 75%); - } - .nmg-recall-pill-resize:hover { - opacity: .9; - } -`,document.head.appendChild(t)})(),()=>{if(e||typeof document>`u`)return;e=!0;let t=document.querySelector(`style[data-plugin-css=`+JSON.stringify(f)+`]`);t&&t.parentNode&&t.parentNode.removeChild(t)}}let m={nmg_search:`#2563eb`,nmg_get:`#0ea5e9`,nmg_remember:`#16a34a`,nmg_board:`#9333ea`,nmg_daemon:`#d97706`},h={"--nmg-text":`#111827`,"--nmg-text-2":`#374151`,"--nmg-text-dim":`#6b7280`,"--nmg-surface":`rgba(0, 0, 0, .04)`,"--nmg-surface-2":`rgba(0, 0, 0, .06)`,"--nmg-border":`rgba(127, 127, 127, .35)`},g={"--nmg-text":`#e5e7eb`,"--nmg-text-2":`#d1d5db`,"--nmg-text-dim":`#9ca3af`,"--nmg-surface":`rgba(255, 255, 255, .07)`,"--nmg-surface-2":`rgba(255, 255, 255, .11)`,"--nmg-border":`rgba(255, 255, 255, .22)`},_=[`nmg_search`,`nmg_get`,`nmg_remember`,`nmg_board`,`nmg_daemon`];function v(e){return(Array.isArray(e)?e:[]).map(e=>e&&e.type===`text`?e.text:``).join(``).replace(/\s+/g,` `).trim()}function y(e){try{let t=JSON.parse(String(e||`{}`));return t&&typeof t==`object`&&!Array.isArray(t)?t:{}}catch{return{}}}function b(e,t){switch(e){case`nmg_search`:return String(t.query||``);case`nmg_get`:return(Array.isArray(t.memoryIds)?t.memoryIds:[]).join(`, `);case`nmg_remember`:return String(t.statement||``)+(t.nodeName?` → `+String(t.nodeName):``);case`nmg_board`:return String(t.action||``)+(t.taskId?` `+String(t.taskId):``);case`nmg_daemon`:return String(t.action||``);default:return``}}function x(e){return e.length<=420?e:e.slice(0,419)+`…`}function S(e){let t=e.block,n=!!(t&&t.kind===`tool-result`),r=e.toolName||t&&(t.name||t.call&&t.call.name)||`nmg`,i=n?t.call?t.call.argsRaw:``:t?t.argsRaw:``,a=d.default.useMemo(()=>y(i),[i]),o=d.default.useMemo(()=>n?v(t.content):``,[n,t]),s=n?!!t.isError:!1,c=m[r]||(e.nmgDark?`#818cf8`:`#6b7280`),l=b(r,a),u=e.nmgDark?g:h;return d.default.createElement(`div`,{className:`nmg-tool-card`+(s?` nmg-tool-error`:``),style:Object.assign({"--nmg-accent":c},u)},d.default.createElement(`div`,{className:`nmg-tool-card-head`},d.default.createElement(`span`,{className:`nmg-tool-badge`},`NMG`),d.default.createElement(`span`,{className:`nmg-tool-name`},r),d.default.createElement(`span`,{className:`nmg-tool-state`},n?s?`error`:`done`:`running`)),l?d.default.createElement(`div`,{className:`nmg-tool-label`},l):null,n?o?d.default.createElement(`pre`,{className:`nmg-tool-result`},x(o)):null:d.default.createElement(`div`,{className:`nmg-tool-running`},`running…`))}let C=d.default.memo(S,(e,t)=>e.callId===t.callId&&e.toolName===t.toolName&&e.block===t.block&&e.nmgDark===t.nmgDark),w=[`slots`,`theme`];function T(e){let t=e.theme,n=new Set,r=`light`,i=()=>{try{let e=t.getTheme();r=e&&e.active&&e.active.colorScheme===`dark`?`dark`:`light`}catch{r=`light`}n.forEach(e=>e())};i();function a(){let[e,t]=d.default.useState(r);return d.default.useEffect(()=>{let e=()=>t(r);return n.add(e),()=>{n.delete(e)}},[]),e}function o(e){let t=a()===`dark`;return d.default.createElement(C,Object.assign({},e,{nmgDark:t}))}let s=`nmg.recall.pill`,c=`nmg.recall.window`;function l(){return{left:window.innerWidth-280,top:80,width:0,height:0}}function u(){return{left:window.innerWidth-420,top:96,width:380,height:300}}function f(e,t){try{let n=window.localStorage.getItem(e);if(!n)return t();let r=JSON.parse(n);if(!r||typeof r!=`object`)return t();let i=t();return{left:typeof r.left==`number`?r.left:i.left,top:typeof r.top==`number`?r.top:i.top,width:typeof r.width==`number`?r.width:i.width,height:typeof r.height==`number`?r.height:i.height}}catch{return t()}}function m(e,t){try{window.localStorage.setItem(e,JSON.stringify(t))}catch{}}function v(e){let t=a()===`dark`,n=e.useSessions(e=>e&&e.current),[r,i]=d.default.useState(null),[o,p]=d.default.useState(!1),[_,v]=d.default.useState(!1),[y,b]=d.default.useState(()=>typeof window>`u`?{left:20,top:80,width:0,height:0}:f(s,l)),[x,S]=d.default.useState(()=>typeof window>`u`?{left:20,top:96,width:380,height:300}:f(c,u)),C=d.default.useRef(null),[w,T]=d.default.useState(0);d.default.useEffect(()=>{if(!n){i(null);return}let e=!0,t=()=>{fetch(`/nmg/recall?session=`+encodeURIComponent(n),{headers:{accept:`application/json`}}).then(e=>e.ok?e.json():null).then(t=>{e&&(t&&t.ok?i(t.data||null):i(null))}).catch(()=>{})};t();let r=window.setInterval(t,5e3);return()=>{e=!1,window.clearInterval(r)}},[n]);let E=o?x:y,D=o?S:b,O=o?c:s,k=d.default.useCallback((e,t)=>{if(e.button!==0)return;e.preventDefault();let n={...E};C.current={mode:t,startX:e.clientX,startY:e.clientY,base:n,key:O,set:D};let r=e=>{let t=C.current;if(!t)return;let n=e.clientX-t.startX,r=e.clientY-t.startY;t.mode===`move`?t.set(e=>({left:Math.max(0,t.base.left+n),top:Math.max(0,t.base.top+r),width:e.width,height:e.height})):t.set(e=>({left:e.left,top:e.top,width:Math.max(260,t.base.width+n),height:Math.max(120,t.base.height+r)}))},i=e=>{window.removeEventListener(`pointermove`,r),window.removeEventListener(`pointerup`,i);let t=C.current;if(t){let n=e.clientX-t.startX,r=e.clientY-t.startY,i=t.mode===`move`?{left:Math.max(0,t.base.left+n),top:Math.max(0,t.base.top+r),width:t.base.width,height:t.base.height}:{left:t.base.left,top:t.base.top,width:Math.max(260,t.base.width+n),height:Math.max(120,t.base.height+r)};m(t.key,i)}C.current=null};window.addEventListener(`pointermove`,r),window.addEventListener(`pointerup`,i)},[o,E,O,D]),A=()=>{let e=!o;m(o?c:s,o?x:y),p(e)},j=r&&Array.isArray(r.recalls)?r.recalls:[],M=j.length>0;if(_)return null;d.default.useEffect(()=>{T(0)},[M]);let N=t?g:h,P=Math.min(w,Math.max(0,j.length-1)),F=M?j[P]:null,I=M?`召回 `+(j[0].candidates?j[0].candidates.length:0)+` 条 · 最近 ~`+(j[0].tokens==null?`?`:j[0].tokens)+` token · 共 `+j.length+` 轮`+(P>0?` (#`+(P+1)+`)`:``):`当前会话暂无召回`,L=Object.assign({"--nmg-accent":`#2563eb`,left:E.left,top:E.top},o?{width:x.width,height:x.height}:{width:`auto`,minWidth:120},N);return d.default.createElement(`div`,{className:`nmg-recall-pill`+(o?` nmg-recall-pill-expanded`:` nmg-recall-pill-collapsed`),style:L},d.default.createElement(`div`,{className:`nmg-recall-pill-head`,style:{cursor:`move`},onPointerDown:e=>{e.button===0&&k(e,`move`)}},d.default.createElement(`span`,{className:`nmg-tool-badge`},`NMG`),d.default.createElement(`span`,{className:`nmg-recall-dock-state`},I),d.default.createElement(`button`,{type:`button`,className:`nmg-recall-pill-toggle`,"aria-label":o?`收起`:`展开`,onPointerDown:e=>e.stopPropagation(),onClick:e=>{e.stopPropagation(),A()}},o?`▾`:`▸`),d.default.createElement(`button`,{type:`button`,className:`nmg-recall-pill-close`,"aria-label":`隐藏`,onPointerDown:e=>e.stopPropagation(),onClick:e=>{e.stopPropagation(),v(!0)}},`✕`)),o&&F?d.default.createElement(`div`,{className:`nmg-recall-pill-body`},(F.candidates||[]).map((e,t)=>d.default.createElement(`div`,{key:e.id||t,className:`nmg-recall-pill-card`},d.default.createElement(`div`,{className:`nmg-recall-pill-meta`},`node=`+e.node+` type=`+e.type+` L`+e.tier),d.default.createElement(`div`,{className:`nmg-recall-pill-preview`},e.preview))),F.activeGraphId?d.default.createElement(`div`,{className:`nmg-recall-pill-meta`},`activeGraphId=`+F.activeGraphId):null,j.length>1?d.default.createElement(`div`,{className:`nmg-recall-pill-nav`},d.default.createElement(`button`,{type:`button`,className:`nmg-recall-pill-navbtn`,disabled:P<=0,onPointerDown:e=>e.stopPropagation(),onClick:e=>{e.stopPropagation(),T(e=>Math.max(0,e-1))}},`‹ 更早`),d.default.createElement(`span`,{className:`nmg-recall-pill-meta`},P+1+` / `+j.length),d.default.createElement(`button`,{type:`button`,className:`nmg-recall-pill-navbtn`,disabled:P>=j.length-1,onPointerDown:e=>e.stopPropagation(),onClick:e=>{e.stopPropagation(),T(e=>Math.min(j.length-1,e+1))}},`更新 ›`)):null):null,o?d.default.createElement(`div`,{className:`nmg-recall-pill-resize`,onPointerDown:e=>{e.stopPropagation(),k(e,`resize`)}}):null)}let y=[p(),e.on(`theme/change`,()=>i())];for(let t of _)y.push(e.slots.inject(`tool.call.toolview`,()=>e.slots.register({name:`tool.call.toolview`,key:t},e=>d.default.createElement(o,e))));return y.push(e.slots.inject(`shell.overlay`,()=>e.slots.register({name:`shell.overlay`,id:`nmg-recall-overlay`,order:50},e=>d.default.createElement(v,e)))),()=>{for(let e of y)typeof e==`function`&&e();n.clear()}}return n.apply=T,n.inject=w,t.exports}}); \ No newline at end of file diff --git a/dsh/dsh-nmg/lib/index.js b/dsh/dsh-nmg/lib/index.js deleted file mode 100644 index 178151c..0000000 --- a/dsh/dsh-nmg/lib/index.js +++ /dev/null @@ -1,1929 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { randomUUID } from "node:crypto"; -import { connect } from "node:net"; -//#region ../../src/integration/config.ts -/** Cross-Agent coordination is available by default. Explicit false-like values -* disable the adapter tool and wake loop without disabling daemon/CLI storage. */ -function coordinationEnabled(environment = process.env) { - const configured = environment.NMG_ENABLE_COORDINATION?.trim().toLowerCase(); - return configured === void 0 || !(/* @__PURE__ */ new Set([ - "0", - "false", - "off", - "no" - ])).has(configured); -} -//#endregion -//#region ../../src/integration/tool-contract.ts -/** Host-neutral action contracts. Adapters keep their native schema library and -* host-only fields, but must not silently omit these shared lifecycle actions. */ -const COMMON_REMEMBER_ACTIONS = [ - "save", - "supersede", - "relate", - "forget", - "resolve", - "reopen", - "claim_outcome" -]; -[...COMMON_REMEMBER_ACTIONS.slice(0, -1)]; -const COMMON_BOARD_ACTIONS = [ - "put", - "read", - "resolve", - "acknowledge", - "claim", - "release", - "unsubscribe", - "subscribe", - "discover" -]; -[...COMMON_BOARD_ACTIONS]; -//#endregion -//#region ../../src/integration/chain-projection.ts -const DEFAULT_LOGICAL_CHAIN_MAX_CHARS = 2048; -function logicalChainNames(result) { - return [...new Set((result.chainMemberships ?? []).filter((membership) => membership.chainType === "logical").map((membership) => membership.topic ?? membership.chainId.slice(0, 8)))]; -} -function logicalChainCount(context) { - return new Set(context.results.flatMap((result) => (result.chainMemberships ?? []).filter((membership) => membership.chainType === "logical").map((membership) => membership.chainId))).size; -} -function memoryLabel(index) { - let value = index + 1; - let label = ""; - while (value > 0) { - value -= 1; - label = String.fromCharCode(65 + value % 26) + label; - value = Math.floor(value / 26); - } - return label; -} -function compactEdgeLines(edges, labels) { - const unique = [...new Map(edges.map((edge) => [`${edge.sourceMemoryId}\0${edge.targetMemoryId}`, edge])).values()]; - const grouped = (incoming) => { - const groups = /* @__PURE__ */ new Map(); - for (const edge of unique) { - const key = incoming ? edge.targetMemoryId : edge.sourceMemoryId; - const member = incoming ? edge.sourceMemoryId : edge.targetMemoryId; - const members = groups.get(key) ?? /* @__PURE__ */ new Set(); - members.add(member); - groups.set(key, members); - } - return [...groups.entries()].map(([key, members]) => { - const keyLabel = labels.get(key); - const memberLabels = [...members].map((id) => labels.get(id)).join(" & "); - return incoming ? `${memberLabels} --> ${keyLabel}` : `${keyLabel} --> ${memberLabels}`; - }); - }; - const outgoing = grouped(false); - const incoming = grouped(true); - const cost = (lines) => lines.reduce((sum, line) => sum + line.length + 1, 0); - return cost(incoming) < cost(outgoing) ? incoming : outgoing; -} -/** -* Host-neutral, budgeted projection of logical chain structure. -* -* Evidence statements remain outside this projection. Adapters render each -* statement once, prefix it with the returned local label, then append `text` -* (or consume `chains` as structured data). -*/ -function projectLogicalChains(context, maxChars = DEFAULT_LOGICAL_CHAIN_MAX_CHARS) { - if (maxChars <= 0) return { - labels: /* @__PURE__ */ new Map(), - chains: [], - foldedChainCount: 0, - text: "" - }; - const available = new Set(context.results.map((result) => result.memory.id)); - const labels = new Map(context.results.map((result, index) => [result.memory.id, memoryLabel(index)])); - const groupedChains = /* @__PURE__ */ new Map(); - for (const result of context.results) for (const membership of result.chainMemberships ?? []) { - if (membership.chainType !== "logical") continue; - const chain = groupedChains.get(membership.chainId) ?? { - topic: membership.topic, - members: [] - }; - chain.members.push({ - memoryId: result.memory.id, - position: membership.position - }); - if (!chain.topic && membership.topic) chain.topic = membership.topic; - groupedChains.set(membership.chainId, chain); - } - const blocks = []; - for (const [chainId, chain] of groupedChains) { - const members = [...chain.members].sort((left, right) => left.position - right.position || left.memoryId.localeCompare(right.memoryId)); - const edges = (context.chainEdges ?? []).filter((edge) => edge.chainId === chainId && available.has(edge.sourceMemoryId) && available.has(edge.targetMemoryId)); - const lines = edges.length > 0 ? compactEdgeLines(edges, labels) : members.length > 1 ? [members.map((member) => labels.get(member.memoryId)).join(" --> ")] : []; - if (lines.length === 0) continue; - const topic = (chain.topic ?? chainId).replace(/[\r\n]+/gu, " ").trim(); - const projected = { - chainId, - topic, - memoryIds: members.map((member) => member.memoryId), - lines - }; - blocks.push({ - chain: projected, - text: `[logical chain: ${topic}]\nflowchart LR\n${lines.map((line) => ` ${line}`).join("\n")}` - }); - } - if (blocks.length === 0) return { - labels: /* @__PURE__ */ new Map(), - chains: [], - foldedChainCount: 0, - text: "" - }; - const open = ""; - const close = ""; - const folded = "[additional logical chains folded by structure budget]"; - const accepted = []; - for (const block of blocks) if ([ - open, - ...accepted.map((item) => item.text), - block.text, - close - ].join("\n").length <= maxChars) accepted.push(block); - if (accepted.length === 0) return { - labels: /* @__PURE__ */ new Map(), - chains: [], - foldedChainCount: blocks.length, - text: "" - }; - const foldedChainCount = blocks.length - accepted.length; - let text = [ - open, - ...accepted.map((item) => item.text), - close - ].join("\n"); - if (foldedChainCount > 0) { - const withFolded = [ - open, - ...accepted.map((item) => item.text), - folded, - close - ].join("\n"); - if (withFolded.length <= maxChars) text = withFolded; - } - const usedMemoryIds = new Set(accepted.flatMap((item) => item.chain.memoryIds)); - return { - labels: new Map([...labels].filter(([memoryId]) => usedMemoryIds.has(memoryId))), - chains: accepted.map((item) => item.chain), - foldedChainCount, - text - }; -} -function searchPreview(memory) { - if ((memory.markers ?? []).some((marker) => marker.kind === "forget")) return "[forget] (content withdrawn)"; - const normalized = memory.statement.replace(/\s+/gu, " ").trim(); - return normalized.length <= 320 ? normalized : `${normalized.slice(0, 319)}…`; -} -/** Agent-facing search projection. Exact records and evidence remain behind `nmg get`. */ -function compactSearchContext(context) { - return { - candidates: context.results.map((result) => ({ - id: result.memory.id, - node: result.node.canonicalName, - type: result.memory.memoryType, - open: result.memory.resolution === "open" || result.memory.resolution === "reopened", - external: (result.memory.markers ?? []).some((marker) => marker.kind === "external_source"), - forgotten: (result.memory.markers ?? []).some((marker) => marker.kind === "forget"), - preview: searchPreview(result.memory), - eventTime: result.memory.eventTime, - expiresAt: result.memory.expiresAt ?? result.memory.validUntil, - chains: logicalChainNames(result) - })), - logicalChainCount: logicalChainCount(context), - activeGraphId: context.activeGraph?.id ?? null, - deferredMemoryIds: context.progressiveDisclosure?.deferredMemoryIds ?? [] - }; -} -//#endregion -//#region ../../src/integration/agent-surface.ts -function excerpt(value, maxLength) { - const normalized = value.replace(/\s+/gu, " ").trim(); - return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 1)}…`; -} -function temporalLabels(candidate) { - const day = (value) => value ? value.slice(0, 10) : null; - const labels = []; - const event = day(candidate.eventTime); - if (event) labels.push(`time=${event}`); - const expires = day(candidate.expiresAt); - if (expires) labels.push(`expires=${expires}`); - return labels; -} -/** -* Host-neutral default rendering for the progressive search surface. Adapters -* may supply host prompt copy, but candidate fields, redaction, chain labels, -* and next-step placement stay identical across hosts. -*/ -function renderSearchSurface(context, options = {}) { - return renderCompactSearchSurface(compactSearchContext(context), options); -} -/** Render only the already-filtered Agent-facing candidate DTO. Storage tiers, -* scores, QPP internals, timings and token estimates never cross this boundary. */ -function renderCompactSearchSurface(context, options = {}) { - if (context.candidates.length === 0) return options.emptyText ?? "No matching NMG memory found."; - const lines = context.candidates.map((candidate) => { - const flags = [candidate.external ? "[external]" : "", candidate.open ? "[open]" : ""].filter(Boolean); - const fields = [ - `memory=${candidate.id}`, - `node=${candidate.node}`, - `type=${candidate.type}`, - ...temporalLabels(candidate), - ...candidate.chains.length > 0 ? [`chains=${candidate.chains.join(",")}`] : [], - `preview=${candidate.preview}` - ]; - return `- ${flags.length > 0 ? `${flags.join(" ")} ` : ""}${fields.join("; ")}`; - }); - return [ - options.preamble, - ...lines, - context.logicalChainCount > 0 ? `logical_chains=${context.logicalChainCount}; use nmg_get for compact chain structure with exact evidence.` : "", - context.activeGraphId ? `activeGraphId=${context.activeGraphId}` : "", - options.postamble - ].filter(Boolean).join("\n"); -} -/** Exact-evidence surface. Statements are emitted once; logical structure has -* an independent budget and refers to stable local labels. */ -function renderEvidenceSurface(context, options = {}) { - const chains = projectLogicalChains(context, options.logicalChainMaxChars ?? 2048); - const records = context.results.map(({ memory, node, evidence }) => { - const external = (memory.markers ?? []).find((marker) => marker.kind === "external_source"); - const forgotten = (memory.markers ?? []).some((marker) => marker.kind === "forget"); - const flags = [ - chains.labels.get(memory.id) ? `[${chains.labels.get(memory.id)}]` : "", - external ? `[external, ${memory.truthStatus}]` : "", - memory.resolution === "open" || memory.resolution === "reopened" ? "[open]" : "" - ].filter(Boolean); - const details = [ - `memory=${memory.id}`, - `node=${node.canonicalName}`, - `type=${memory.memoryType}`, - `truth=${memory.truthStatus}`, - `scope=${JSON.stringify(memory.scope)}`, - ...memory.eventTime ? [`time=${memory.eventTime}`] : [] - ]; - const externalSource = external?.attributes?.source ? `\n EXTERNAL_SOURCE=${String(external.attributes.source)}; retrievedAt=${String(external.attributes.retrievedAt ?? "unknown")}` : ""; - const source = !forgotten && evidence.content.trim() !== memory.statement.trim() ? `\n SOURCE=${excerpt(evidence.content, 320)}` : ""; - const statement = forgotten ? "[forget] (content withdrawn)" : memory.statement; - return `- ${flags.length > 0 ? `${flags.join(" ")} ` : ""}${statement}\n ${details.join("; ")}${externalSource}${source}`; - }); - const missing = options.missingMemoryIds?.length ? `MISSING: ${options.missingMemoryIds.join(", ")}` : ""; - return [ - options.preamble, - ...records, - missing, - chains.text, - options.postamble - ].filter(Boolean).join("\n") || options.emptyText || "No active memory found."; -} -/** Default follow-up guidance after a durable save. The model remains the -* semantic judge; NMG only exposes bounded candidates. */ -function renderRememberSurface(result) { - const memoryId = result.memory?.id; - const lines = [`Saved${memoryId ? ` ${memoryId}` : " memory"}.`]; - const supersede = (result.supersedeCandidates ?? []).slice(0, 3); - if (supersede.length > 0 && memoryId) lines.push("NMG found possible older values. Similarity is only a candidate signal; decide semantically.", ...supersede.map((candidate) => `- ${candidate.memoryId}: ${excerpt(candidate.statement, 180)}`), "If exactly one candidate is genuinely replaced in the same scope, call nmg_remember again with action=supersede, newMemoryId, supersededMemoryId, and a short reason. Otherwise do nothing."); - const duplicates = (result.duplicates ?? []).filter((candidate) => candidate.memoryId !== memoryId); - if (duplicates.length > 0) lines.push("Possible semantic neighbours were retained as distinct nodes:", ...duplicates.slice(0, 3).map((candidate) => `- ${candidate.memoryId}: ${excerpt(candidate.statement, 180)}`), "Only if a relationship is useful, call nmg_remember again with action=relate, newMemoryId, relatedMemoryId, and relationJudgement. Similarity alone is not identity; otherwise do nothing."); - return lines.join("\n"); -} -const TASK_BOARD_CONVENTIONS = "Board conventions (on use): entries may carry memory= references to LTG records — readers expand them with nmg_get; open entries can be claimed by one Agent (lease-based, expired claims return to the pool) and released; resolve a request once it is answered — a resolved entry is closed and must not be replied to (reopen only with new substance); keep entries concise and temporary; taskId is the only channel boundary (no DMs, mentions, groups, or pinning)."; -/** Host-neutral board rendering. Host-only actions such as Pi rename remain in -* the adapter and can bypass this renderer. */ -function renderTaskBoardSurface(result, options) { - if (result.action === "discover") { - const agents = result.agents ?? []; - return agents.length === 0 ? "No online NMG agents match the requested capability." : [ - "Online NMG agents:", - ...agents.map((agent) => `- ${agent.agentName}${agent.description ? ` — ${agent.description}` : ""}${agent.capabilities ? ` capabilities=${agent.capabilities}` : ""} (id=${agent.id ?? agent.agentName}; lastSeen=${agent.lastSeenAt})`), - "Use nmg_board action=put with to= for directed delivery." - ].join("\n"); - } - const entries = result.entries ?? (result.entry ? [result.entry] : []); - const lines = []; - for (const board of options.directory ?? []) { - if (lines.length === 0) lines.push("Active named channels (world channel lobby):"); - lines.push(`- ${board.taskId} (${board.entryCount} open · updated ${board.lastUpdatedAt.slice(0, 10)})`); - } - if (lines.length > 0) lines.push(""); - if (entries.length === 0) lines.push(options.emptyText ?? `Task board ${options.taskId} has no matching entries.`); - else { - for (const entry of entries) { - const claim = entry.claimedBy ? ` [claimed by ${entry.claimedBy}]` : ""; - const ack = entry.ackedBy?.length ? ` (✅ acked by ${entry.ackedBy.join(", ")})` : ""; - lines.push(`- #${String(entry.sequence ?? "?")} ${String(entry.id ?? "?")} [${String(entry.kind ?? "entry")}/${String(entry.status ?? "open")}]${claim}${ack} ${String(entry.agentId ?? "unknown")}: ${excerpt(String(entry.content ?? ""), 500)}`); - } - if (result.action === "read") lines.push(`nextCursor=${String(result.nextCursor ?? 0)}`); - } - lines.push("Temporary coordination only; use nmg_remember separately for durable knowledge."); - if (options.includeConventions !== false) lines.push(TASK_BOARD_CONVENTIONS); - return lines.join("\n"); -} -//#endregion -//#region ../../src/prompts/nmg-prompts.generated.ts -const GENERATED_NMG_PROMPTS = { - search_description: "Search long-term memory. Queries match saved records by literal terms, semantic similarity, and learned graph routes; returns ranked candidate headers (memory id, node, type, match terms, event date, preview) plus an activeGraphId. Exact records and source evidence are loaded via nmg_get. The store only contains what was explicitly saved - it is not a live source. Advanced syntax: key:value filters (type:preference,constraint node:\"Conversation ...\" state:key time:2026-01-01..2026-06-30), -term exclusions, and quoted phrases.", - get_description: "Load exact memory records and source evidence for memory IDs returned by nmg_search (or nmg_automatic_recall). When available, pass the activeGraphId returned by search so NMG can attribute actual use; it is not required to read a known memory ID. Returns the full statement plus evidence content and source reference.", - remember_description: "Save a durable record (fact, state, event, preference, constraint, strategy), resolve a possible supersession, attach explicit evaluation feedback to a retrieval trace, or record an attributable supported/contradicted outcome for selected saved claims. You decide the semantic content and whether a candidate is truly an older value; NMG validates scope, identity, provenance, and applies the transaction. The record persists across sessions. Only save attributable information - not secrets, transient content, or unsupported guesses. Never label an Assistant inference as a user statement. A stateKey names one replaceable property within a stable scope; it is not a topic or grouping tag. recallTriggers may name a few short aliases or likely query phrases that should retrieve this record when its factual statement uses different wording.", - mcp_remember_description: "Save a durable record or apply an explicit memory lifecycle action. Supports save, supersede, relate, forget, resolve, reopen, and task-attributed claim_outcome. The MCP adapter cannot verify user/tool message evidence and does not expose Pi-local retrieval feedback, so claim_outcome accepts only a task source with an explicit source lineage. NMG validates scope, identity, provenance, and applies the transaction.", - board_description: "Coordinate through a temporary blackboard shared by NMG clients. Without a taskId, entries land on the shared world channel; reading it lists active named channels you can join by name. Entries expire automatically and retain writer attribution (NMG_AGENT_ID). Use put/read/resolve; full conventions (memory pointers, boundaries) are disclosed in each tool result.", - lab_description: "Discover and temporarily enable optional NMG capabilities for the current session. Use list before first use, enable only when the current task benefits, invoke a documented operation, and disable when finished. Reasoning workspace, graph reasoner, and controller shadow are self-service; controlled or active controller modes remain harness/operator gated. Lab output is provisional and never becomes durable memory unless separately submitted through nmg_remember.", - reason_description: "Maintain a session-private, auditable reasoning scratchpad containing typed goals, observations, hypotheses, evidence, conclusions, decisions, open questions, next actions, and explicit relations. It persists checkpoints across Pi compaction and process restart but never writes scratch content into durable semantic memory. Evidence nodes require stable source references; supported nodes require either such a reference or an anchored support path. Unsupported hypotheses remain visibly marked as unsupported after compaction.", - reason_action_parameter_description: "add creates one typed scratch node and idempotently reuses an exact same-kind, same-content node; update is required for semantic change; link records one explicit relation; checkpoint returns a bounded current view; clear deletes only this session's reasoning scratchpad.", - reason_node_id_parameter_description: "Stable reasoning-node ID returned by an earlier nmg_reason call. Required for update and scoped to the current Pi session.", - reason_kind_parameter_description: "The role of a new scratch node in the current reasoning process. Required for add; it is not a durable MemoryNode type.", - reason_content_parameter_description: "Concise scratch content for add, or replacement content for update. Keep uncertainty explicit; scratch content is not verified fact.", - reason_status_parameter_description: "Epistemic or workflow status of the scratch node. supported means the node has traceable support; rejected preserves a disproven path; resolved and superseded keep lifecycle history.", - reason_importance_parameter_description: "Session-local checkpoint priority from 0 to 1. It affects bounded disclosure, not truth, durability, or automatic promotion.", - reason_evidence_refs_parameter_description: "Stable references supporting a new or updated scratch node, such as NMG memory IDs, source-message IDs, file locations, or tool-result identities. Required for evidence nodes and for directly supported nodes; alternatively mark a node supported only after linking an already anchored source with supports or derived_from. References are retained for audit but are not independently verified by this tool.", - reason_source_id_parameter_description: "Existing reasoning-node ID at the source of a relation. Required for link.", - reason_target_id_parameter_description: "Existing reasoning-node ID at the target of a relation. Required for link.", - reason_relation_parameter_description: "Explicit directed relation between two existing scratch nodes. Required for link and scoped to the current Pi session.", - board_action_parameter_description: "Use put to publish one concise coordination entry, read to fetch entries after an optional cursor, claim to take an open entry as the single working Agent (lease-based; expired claims return to the pool, resolving clears the claim), release to put a claimed entry back, unsubscribe to leave a channel (stop receiving its wake notices; the world channel is your default member channel and can be muted the same way), subscribe to join a channel (topic-based membership — a named channel only wakes its members, never non-members), acknowledge to record that you have seen and accepted an entry and owe no reply (pure state write — no broadcast, no wake, and the acked entry stops re-notifying you), or resolve to close a selected entry. discover lists online agents (A2A roster, by capability) so you can pick a target, then put ... to= for directed delivery — only that agent's LLM is woken, everyone else reads but stays silent (find first via discover, then direct). Un-directed actionable entries (handoff/question/ blocker) are serialised per channel: only one outstanding is pushed at a time; later ones sit 'pending' (read-visible, silent) until the outstanding one is claimed (接手=回复) or resolved, which promotes the next. Directed entries are exempt (parallel-safe). kind decides wake routing: question/blocker/handoff ask for a response and WAKE subscribers; note/result/decision/goal are notify-only and SILENT (they record a fact, owe no reply, and are read on demand — never pushed). If you need an answer or action, use question/handoff; never smuggle a request into a note (a silent note asking for something starves). When a fact is already confirmed, do not post a confirming note — acknowledge it (one ack is enough; everyone confirming one fact is an acknowledgement storm). Confirmations close: once a question/handoff is answered, resolve it — a resolved entry is closed and should not be replied to (reopen only for new substance). Acknowledgement is the lightweight \"确认但不用回\": for a result/note you accept, prefer acknowledge over posting a new reply entry — acknowledgers are visible in read output (✅ acked by ...) and readers check them on demand instead of being broadcast every Ack. Put/read/claim/release/ resolve/acknowledge do not create semantic memory. To persist a board conclusion as durable memory WITHOUT duplicating it: (1) nmg_search first — if the fact already exists as a memory, do not\n remember it again; point the board entry at it with memory=.\n(2) only if no existing memory covers it, nmg_remember with\n boardSource: {taskId, entryId} (records a board_origin marker so the\n memory traces back to this board entry and later dedup can recognize\n it). (3) resolve the board entry once the memory is created, noting\n memory= in the resolution.", - remember_board_source_parameter_description: "Provenance link from a task board entry to the durable memory being created from its content. When set, nmg_remember attaches a board_origin marker ({kind: \"board_origin\", attributes: {taskId, entryId}}) so the memory traces back to the board entry and later writes of the same board content can be recognized as duplicates. Use it ONLY when creating a NEW memory from board content that has no existing memory counterpart (search first).", - board_task_id_parameter_description: "Optional. Name of the channel to read or write. When omitted, entries land on the shared world channel (the default), which every Agent reads; reading the world channel also lists active named channels so you can discover and join one by name.", - board_content_parameter_description: "Concise task coordination state such as a goal, blocker, result, handoff, question, or decision. Do not include secrets or hidden chain-of-thought. To point other Agents at durable knowledge, include memory= references (the same format automatic recall uses); readers expand them with nmg_get.", - remember_action_parameter_description: "Omit or use save for a new memory. Use supersede only when a candidate is the older value of the new memory. Use relate to propose a semantic relationship without merging node identity; the proposal remains reversible and pending. Use forget only when the user explicitly asks to withdraw a selected memory. Use resolve when an open question or dependency has been settled. Use reopen when later evidence makes a resolved memory actionable again; provide the related evidence memory IDs so the open structure stays attributable. Use feedback to record explicit task/retrieval labels. Use claim_outcome only after an attributable user, tool, or completed task explicitly supports or contradicts selected saved claims; retrieval, use, silence, and task success alone are not claim outcomes. Pass activeGraphId when labelling a specific older retrieval; omit it only to label the latest retrieval in the current Pi session. Silence or lack of correction is not positive feedback.", - mcp_remember_action_parameter_description: "Omit or use save for a new memory. Use supersede only when the selected older value has the same scope, relate to create a reversible semantic proposal, forget only for an explicit withdrawal, and resolve or reopen for an open memory lifecycle change. Use claim_outcome only for an independently attributable completed task, with semanticTaskId and claimSourceLineage. The MCP adapter does not accept Pi-local retrieval feedback or user/tool claim evidence because it cannot verify those current-session message sources.", - remember_memory_id_parameter_description: "For action=forget, resolve, or reopen, the exact target memory ID. This creates an auditable logical deletion, not physical privacy erasure.", - remember_new_memory_id_parameter_description: "For action=supersede, the new memory ID returned by the preceding save.", - remember_superseded_memory_id_parameter_description: "For action=supersede, the candidate memory ID whose value is replaced. Do not use this merely because two memories are related or similar.", - remember_related_memory_id_parameter_description: "For action=relate, a candidate memory ID whose node has a meaningful semantic relationship with the newly saved memory. This does not merge either node.", - remember_relation_judgement_parameter_description: "For action=relate, classify the nodes as same_entity, related, refines, conflict, or distinct. Use same_entity only for identity, not similarity; use distinct when an apparent match is actually a different entity or scope.", - node_name_parameter_description: "Stable semantic cluster name. Related memories may share a node even when they describe different properties.", - recall_triggers_parameter_description: "Optional short aliases or likely query phrases that should recall this record when the saved statement uses different wording. Use only distinctive wording a future user or Agent may actually search for; do not copy the whole statement, add broad topic tags, or encode new facts here. At most 16 entries.", - state_key_parameter_description: "Stable key for exactly one replaceable property within one canonical scope. Reuse the key when the new value makes the old value no longer current; for example, running.charity_5k.personal_best changes from 27:12 to 25:50. Use different keys when both values can remain true; for example, a personal best and a target time are distinct properties. Good: pi-lsp.installation.path. Bad: pi-lsp-env used for installation path, tool inventory, and patch policy. A newer state with the same key and canonical scope automatically supersedes the previous active state, so a broad or wrongly reused key can incorrectly retire an unrelated state.", - external_source_parameter_description: "External provenance reference beginning with web: or file:.", - evidence_parameter_description: "Smallest exact excerpt that supports the saved statement. Copy it from the user, assistant, or tool source identified by sourceActor; do not summarize or include surrounding routine prose. Pi binds a matching excerpt to its source message. Use externalSource for web/file evidence.", - source_actor_parameter_description: "Author of the supporting evidence, not the desired authority of the memory. Omit only for Assistant-created content (the safe default). Use user, tool, or system only with an exact evidence excerpt from that source in the current Pi session, or with an explicit externalSource for tool-derived web/file evidence. Never mark an Assistant inference or summary as sourceActor=user.", - active_graph_id_parameter_description: "Optional activeGraphId returned by nmg_search. For action=feedback, omit it to target the latest retrieval owned by the current Pi session; pass it to label a specific older retrieval. For nmg_get, pass it when available to attribute use.", - mcp_active_graph_id_parameter_description: "Optional activeGraphId returned by nmg_search. Pass it to nmg_get when available so exact disclosure is attributed to this MCP session, or to claim_outcome when the selected claim came from that retrieval.", - feedback_note_parameter_description: "Optional concise reason for an explicit retrieval/task label. Do not include hidden reasoning, secrets, or infer success merely because no correction was made.", - feedback_label_parameter_description: "Explicit observed label for action=feedback. Omit when unknown; false means the condition was actually reviewed and absent, not merely that nobody said it.", - semantic_task_id_parameter_description: "Optional stable identifier shared by retries of the same semantic task. It is used for calibration deduplication, not session authorization or memory scope.", - search_query_parameter_description: "Natural-language retrieval clause; may include key:value filters, -term exclusions, and quoted phrases.", - search_queries_parameter_description: "Optional Agent-generated retrieval clauses, such as query decomposition, alternate wording, or a hypothetical-answer (HyDE) clause. NMG does not generate these clauses; it searches them, removes duplicate records, and appends unique results after the primary query's ranking.", - search_progression_required: "NMG paused another search because two searches were already completed without loading selected evidence. Use nmg_get on relevant returned memory IDs, or use a current-source tool when the missing fact concerns live code, files, or the web. Do not keep paraphrasing the same historical search.", - search_recommendation: "Automatic recall may be incomplete. If prior experience could change the answer, make one deliberate nmg_search call and then inspect only useful IDs with nmg_get.", - completion_nudge: "A code commit or task completion was just detected. NMG long-term memory is available: you may recall relevant decisions (nmg_search) or save this turn's key conclusions (nmg_remember) if useful. This is only a reminder; it does not affect the current work.", - shadow_feedback_nudge: "The previous NMG retrieval activeGraphId={active_graph_id} has completed and remains unlabelled. If the preceding answer and tool evidence let you review it, call nmg_remember action=feedback with semanticTaskId={semantic_task_id} and explicit values for evidenceSufficient, expansionUseful, excessiveNoise, and noMemoryNeeded. Omit the feedback call when any required label would be a guess. Silence, task completion, and lack of user correction are not labels.", - shadow_claim_outcome_nudge: "The previous NMG retrieval activeGraphId={active_graph_id} disclosed saved memory IDs {memory_ids} through exact get and completed under semanticTaskId={semantic_task_id}. Inspect only the current user message or a successful tool result from this new turn. If it independently and unambiguously supports or contradicts one of that graph's disclosed saved claims, call nmg_remember action=claim_outcome with the exact memoryId, activeGraphId, semanticTaskId, claimOutcomeSource=user or claimOutcomeSource=tool, and an exact evidence excerpt. Otherwise omit the call. Answer overlap, retrieval, task success, silence, lack of correction, and failed tool output are not claim evidence.", - search_disclosure: "NMG MEMORY CANDIDATES\nformat: one candidate per line; fields separated by \"; \".\nfields: memory=id; node=semantic cluster; type=memory type; time=event date; expires=expiry date; chains=logical chain names; preview=statement excerpt\nOptional [external] and [open] markers describe provenance and unresolved state.\nRecords are ranked by retrieval score; order is not a guarantee of relevance.\n", - search_disclosure_metadata: "NMG search metadata: count={count}\n{next_step}\n{forget_hint}\n", - get_disclosure: "NMG selected evidence:\n- statement: the saved record content\n- SOURCE=: original evidence content when it differs from the statement\n- id/node/type/truth/scope identify the record\n", - get_disclosure_metadata: "NMG evidence metadata: count={count}\n{next_step}\n{forget_hint}\n", - deferred_hint: "More ranked records are folded. If these are insufficient, call nmg_get once with the deferred memory IDs; do not repeat nmg_search.", - get_hint: "Use nmg_get with selected memory IDs and the activeGraphId to load exact records and source evidence.", - forget_hint: "A line beginning with [forget] is a revocation boundary; treat it as revoked.", - in_context_title: "NMG ALREADY IN CURRENT CONTEXT", - memory_policy: "NMG supplies durable memory; it does not decide answer truth or evidence completeness. nmg_automatic_recall and nmg_search return candidate headers. nmg_get loads selected exact records and source evidence. nmg_remember saves durable information. Automatic recall uses a bounded current-task context, but can still miss a relevant domain. For the latest user request, decide which candidates matter, whether one or several records are needed, and whether more recall or current verification is required. If the current task clearly depends on prior project state and automatic headers are absent or insufficient, use nmg_search before guessing; this is not a requirement to search on every turn. NMG is historical memory, not a live code/file/web source: after one deliberate search and selected get fail to supply the missing fact, stop reformulating the same request and either use an appropriate current-source tool or state that the current fact was not verified. No useful memory is a valid result. Do not treat candidate count as completeness or a memory as current truth. Semantic or topical relevance does not prove the specific attribute, event, reason, or causal relation being asked about. If the recalled headers under-determine the answer, request additional evidence (append/re-fetch) before guessing; ignore headers that are clearly irrelevant noise. When an exact current-session user message or successful tool result independently supports or contradicts a disclosed saved claim, record nmg_remember action=claim_outcome with that exact excerpt; otherwise omit it. Retrieval, answer reuse, task completion, silence, or lack of correction are not claim evidence. Without waiting for an explicit \"remember\" request, save newly confirmed durable user facts, preferences, constraints, decisions, and reusable experience when they are likely to matter in a later session. Reuse an existing state key and scope for updates; search first when duplication is plausible. Ask instead of saving when attribution, scope, or durability is materially ambiguous. Save only attributable, durable information; do not save secrets, transient content, unconfirmed assistant proposals, unsupported guesses, or information already stored.", - claim_outcome_parameter_description: "Explicit result for selected saved claims: supported or contradicted. This is evidence about the claim itself, not retrieval quality. Never infer support merely because a memory was retrieved, used in an answer, or not corrected.", - claim_outcome_source_parameter_description: "Attribution class for the outcome: user, tool, or task. User/tool outcomes require an exact evidence excerpt from the current Pi session; task outcomes require claimSourceLineage. Benchmark outcomes are reserved for eval/CLI.", - claim_source_lineage_parameter_description: "Stable identity of an independently attributable completed task. For user/tool outcomes Pi derives lineage from the exact evidence message instead of trusting a model-provided ID. Reusing lineage prevents one source from becoming multiple independent votes.", - claim_indexes_parameter_description: "Optional zero-based indexes of atomic claims within the memory. Omit to apply the explicit outcome to every claim in that memory." -}; -//#endregion -//#region ../../src/prompts/load.ts -function loadPrompts() { - return GENERATED_NMG_PROMPTS; -} -/** -* Progressive-disclosure renderer: substitutes {placeholders} with the given -* values (missing ones become empty) and drops the lines they leave empty. -* Unknown placeholders are left untouched. -*/ -function renderDisclosure(template, vars) { - return template.replace(/\{(\w+)\}/g, (_match, key) => vars[key] ?? _match).split("\n").filter((line) => line.trim().length > 0).join("\n"); -} -//#endregion -//#region src/plugin/index.ts -const nmgPrompts = loadPrompts(); -const inject = [ - "tools", - "subprocess", - "sandboxPolicy", - "systemPrompt", - "timer" -]; -/** Tool output shape: a single pre-rendered text block. */ -const textOutput = { - schema: { type: "string" }, - render: (_args, value) => [{ - type: "text", - text: String(value) - }] -}; -/** CJK-aware fallback token estimator used only when the daemon is down. */ -function estimateTokens(text) { - let cjk = 0; - let latin = 0; - for (const ch of String(text || "")) if (ch.codePointAt(0) > 12287) cjk += 1; - else if (!/\s/.test(ch)) latin += 1; - return Math.ceil(cjk + latin / 4); -} -function apply(ctx) { - const tools = ctx.tools; - const subprocess = ctx.subprocess; - const sandboxPolicy = ctx.sandboxPolicy; - const systemPrompt = ctx.systemPrompt; - const coordinationEnabled$1 = coordinationEnabled(); - const workspaceRoot = process.env.NMG_PROJECT_DIR && process.env.NMG_PROJECT_DIR.trim() || sandboxPolicy && typeof sandboxPolicy.workspaceRoot === "string" && sandboxPolicy.workspaceRoot || "C:\\Documents\\GitHub\\NodeMemoryGraph"; - const binPath = workspaceRoot.replace(/[\\/]+$/, "") + "\\bin\\nmg.mjs"; - let nodePromise; - function resolveNode() { - if (nodePromise === void 0) nodePromise = subprocess.resolveExecutable("node").catch(() => "node"); - return nodePromise; - } - function truncate(value, max) { - const t = String(value == null ? "" : value).replace(/\s+/g, " ").trim(); - return t.length <= max ? t : t.slice(0, max - 1) + "…"; - } - function clampInt(raw, min, max, fallback) { - const value = Number(raw); - return Number.isFinite(value) ? Math.max(min, Math.min(max, Math.floor(value))) : fallback; - } - function isProcessAlive(pid) { - if (!Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error.code !== "ESRCH"; - } - } - let daemon = null; - function resolveDaemon() { - if (daemon) return daemon; - try { - const home = process.env.USERPROFILE || process.env.HOME || ""; - const envDir = (process.env.NMG_DATA_DIR || "").replace(/[\\/]+$/, ""); - const candidates = []; - if (envDir) candidates.push(envDir); - candidates.push(join(home, ".nmg")); - const projectDir = join(workspaceRoot, ".nmg"); - if (!candidates.includes(projectDir)) candidates.push(projectDir); - for (const dataDir of candidates) try { - const state = JSON.parse(readFileSync(join(dataDir, "nmg.sqlite.server.json"), "utf8")); - if (state.transport !== "http" || !state.host || !state.port || !state.token) continue; - if (!isProcessAlive(state.pid)) continue; - daemon = { - host: state.host, - port: state.port, - token: state.token, - pid: state.pid - }; - return daemon; - } catch {} - } catch { - daemon = null; - } - return daemon; - } - async function daemonCall(method, params, signal) { - const endpoint = resolveDaemon(); - if (!endpoint) return null; - try { - const response = await fetch("http://" + endpoint.host + ":" + endpoint.port + "/", { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer " + endpoint.token - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method, - params, - id: 1 - }), - signal - }); - const text = await response.text(); - if (!response.ok) throw new Error(text || "nmg " + method + " failed (" + response.status + ")"); - const parsed = JSON.parse(text); - if (parsed.error) throw new Error(parsed.error.message || "nmg " + method + " error"); - return parsed.result; - } catch (error) { - if (error && error.name === "AbortError") throw error; - daemon = null; - return null; - } - } - function probePort(host, port, timeoutMs = 800) { - return new Promise((resolve) => { - let socket; - try { - socket = connect({ - host, - port - }); - } catch { - return resolve(false); - } - let done = false; - const finish = (ok) => { - if (done) return; - done = true; - try { - socket.destroy(); - } catch {} - resolve(ok); - }; - socket.setTimeout(timeoutMs); - socket.once("connect", () => finish(true)); - socket.once("timeout", () => finish(false)); - socket.once("error", () => finish(false)); - }); - } - async function ensureDaemon(signal) { - let endpoint = resolveDaemon(); - if (endpoint && await probePort(endpoint.host, endpoint.port)) return endpoint; - if (endpoint && endpoint.pid) try { - process.kill(endpoint.pid); - } catch {} - daemon = null; - try { - await runNmg(["daemon", "start"], signal); - await new Promise((resolve) => setTimeout(resolve, 2500)); - } catch {} - daemon = null; - endpoint = resolveDaemon(); - if (endpoint && await probePort(endpoint.host, endpoint.port)) return endpoint; - return null; - } - async function runNmg(args, signal) { - const node = await resolveNode(); - const handle = subprocess.spawn({ - argv: [node, binPath].concat(args), - cwd: workspaceRoot, - stdio: { - stdin: "ignore", - stdout: { maxBytes: 524288 }, - stderr: { maxBytes: 65536 } - }, - graceMs: 8e3, - signal - }); - const outcome = await handle.done; - const stdout = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ""; - const stderr = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ""; - return { - exitCode: outcome.exitCode, - stdout, - stderr - }; - } - async function nmgJson(args, signal) { - let run; - try { - run = await runNmg(args, signal); - } catch (error) { - return { - ok: false, - error: "NMG spawn failed: " + truncate(error && error.message ? error.message : String(error), 500) - }; - } - if (run.exitCode !== 0) { - const detail = (run.stderr || run.stdout || "").trim(); - return { - ok: false, - error: "NMG exit " + run.exitCode + (detail ? ": " + truncate(detail, 500) : "") - }; - } - let data; - try { - data = JSON.parse(run.stdout); - } catch { - return { - ok: false, - error: "NMG non-JSON output: " + truncate(run.stdout, 500) - }; - } - return { - ok: true, - data - }; - } - function scopeArgs(scope) { - if (scope === null || typeof scope !== "object") return []; - return Object.keys(scope).map((key) => ["--scope", String(key) + "=" + String(scope[key])]); - } - function coerceScope(scope) { - const out = {}; - for (const key of Object.keys(scope || {})) out[key] = String(scope[key]); - return out; - } - function projectDaemonStatus(raw) { - const endpoint = resolveDaemon(); - return { - running: true, - pid: endpoint && endpoint.pid != null ? endpoint.pid : null, - endpoint: endpoint ? endpoint.host + ":" + endpoint.port : null, - compatible: true, - status: raw - }; - } - async function invoke(method, params, cliArgs, signal, project) { - try { - const raw = await daemonCall(method, params, signal); - if (raw != null) return { - ok: true, - data: project ? project(raw) : raw - }; - } catch { - return { - ok: false, - error: "NMG call aborted" - }; - } - return nmgJson(cliArgs, signal); - } - async function invokeRpcOnly(method, params, signal) { - try { - let raw = await daemonCall(method, params, signal); - if (raw == null) { - await ensureDaemon(signal); - raw = await daemonCall(method, params, signal); - } - return raw == null ? { - ok: false, - error: "NMG daemon is unavailable for " + method - } : { - ok: true, - data: raw - }; - } catch { - return { - ok: false, - error: "NMG call aborted" - }; - } - } - const recallWindows = /* @__PURE__ */ new Map(); - const sessionTokenTotals = /* @__PURE__ */ new Map(); - const recallBatch = /* @__PURE__ */ new Map(); - const MAX_RECALL_HISTORY = 5; - const openSearches = /* @__PURE__ */ new Map(); - function nextGeneration(sessionId) { - let window = recallWindows.get(sessionId); - if (!window) { - window = { - generation: 0, - injected: /* @__PURE__ */ new Map() - }; - recallWindows.set(sessionId, window); - } - window.generation += 1; - return window; - } - function filterRecallCandidates(window, generation, candidates) { - const fresh = []; - for (const candidate of candidates || []) { - const previousGeneration = window.injected.get(candidate.id); - if (previousGeneration != null && generation - previousGeneration <= 12) continue; - fresh.push(candidate); - } - for (const [id, injectedGeneration] of window.injected) if (generation - injectedGeneration > 12) window.injected.delete(id); - return fresh; - } - function extractUserPrompt(message) { - const parts = []; - if (!message || typeof message !== "object") return ""; - for (const block of message.content || []) if (block.type === "text" && block.text) parts.push(block.text); - const joined = parts.join(" ").replace(/\s+/g, " ").trim(); - return joined.length > 500 ? joined.slice(0, 500) : joined; - } - function formatRecall(recall, candidates) { - const chainCount = new Set(candidates.flatMap((candidate) => candidate.chains || [])).size; - const deferred = Array.isArray(recall.deferredMemoryIds) ? recall.deferredMemoryIds : []; - const nextStep = deferred.length ? nmgPrompts.deferred_hint + " Memory IDs: " + deferred.join(",") : nmgPrompts.get_hint; - const forget = candidates.some((candidate) => candidate.forgotten); - return renderCompactSearchSurface({ - candidates, - logicalChainCount: chainCount, - activeGraphId: recall.activeGraphId || null, - deferredMemoryIds: deferred - }, { preamble: renderDisclosure(nmgPrompts.search_disclosure, { - count: String(candidates.length), - next_step: nextStep, - forget_hint: forget ? nmgPrompts.forget_hint : "" - }) }); - } - function recallBudget(signal) { - try { - return AbortSignal.any([signal, AbortSignal.timeout(1500)]); - } catch { - return signal; - } - } - async function autoRecall(query, sessionId, signal) { - const limit = clampInt(process.env.NMG_AUTO_RECALL_LIMIT, 1, 50, 13); - const tier = clampInt(process.env.NMG_AUTO_RECALL_TIER, 0, 3, 1); - const budget = recallBudget(signal); - try { - const context = await daemonCall("search", { - query, - limit, - maxTier: tier, - graphHops: 1, - tieredDisclosure: true, - projectDir: workspaceRoot, - sessionId - }, budget); - if (context) return compactSearchContext(context); - } catch { - return null; - } - const result = await nmgJson([ - "search", - query, - "--limit", - String(limit), - "--max-tier", - String(tier), - "--graph-hops", - "1", - "--tiered-disclosure", - "--project-dir", - workspaceRoot, - "--session-id", - sessionId, - "--compact-json" - ], budget); - return result.ok ? result.data : null; - } - function onInboxInserted(payload) { - try { - const { agent, message } = payload || {}; - if (!agent || !message) return; - lastAgents.set(String(agent.id), agent); - const sessionId = String(agent.id); - const query = extractUserPrompt(message); - if (!query) return; - const window = nextGeneration(sessionId); - const generation = window.generation; - const run = (async () => { - try { - const recall = await autoRecall(query, sessionId, void 0); - if (!recall || !Array.isArray(recall.candidates) || recall.candidates.length === 0) return; - const fresh = filterRecallCandidates(window, generation, recall.candidates); - if (fresh.length === 0) return; - const thisTokens = recall.tokens != null ? recall.tokens : estimateTokens(fresh.map((c) => c.preview).join(" ")); - const sessionTotal = (sessionTokenTotals.get(sessionId) || 0) + thisTokens; - sessionTokenTotals.set(sessionId, sessionTotal); - const text = formatRecall(recall, fresh); - const entry = { - generation, - text, - tokens: thisTokens, - sessionTotal, - candidates: fresh, - activeGraphId: recall.activeGraphId - }; - const history = recallBatch.get(sessionId); - recallBatch.set(sessionId, [entry, ...history || []].slice(0, MAX_RECALL_HISTORY)); - for (const candidate of fresh) window.injected.set(candidate.id, generation); - } catch {} - })(); - openSearches.set(sessionId, run); - run.finally(() => { - if (openSearches.get(sessionId) === run) openSearches.delete(sessionId); - }); - } catch {} - } - function recallTextFor(agent) { - if (!agent) return ""; - try { - const stack = recallBatch.get(String(agent.id)); - const latest = Array.isArray(stack) ? stack[0] : stack; - return latest && latest.text ? latest.text : ""; - } catch { - return ""; - } - } - function recallDataFor(sessionId) { - if (!sessionId) return null; - try { - const stack = recallBatch.get(String(sessionId)); - const list = Array.isArray(stack) ? stack : stack ? [stack] : []; - if (list.length === 0) return null; - return { recalls: list.map((snapshot) => ({ - generation: snapshot.generation, - tokens: snapshot.tokens, - sessionTotal: snapshot.sessionTotal, - activeGraphId: snapshot.activeGraphId || null, - candidates: (snapshot.candidates || []).map((c) => ({ - id: c.id, - node: c.node, - type: c.type, - tier: c.tier, - preview: c.preview - })) - })) }; - } catch { - return null; - } - } - function onAgentDisposed(payload) { - try { - if (payload && payload.agent) { - const id = String(payload.agent.id); - recallWindows.delete(id); - sessionTokenTotals.delete(id); - recallBatch.delete(id); - openSearches.delete(id); - wakeBatch.delete(id); - lastAgents.delete(id); - } - } catch {} - } - const hostSessionId = (process.env.DSH_SESSION_ID || "").trim() || "dsh"; - const projectName = workspaceRoot.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "dsh"; - const WAKE_AGENT_ID = "dsh:" + projectName; - const WAKE_AGENT_NAME = projectName; - const WAKE_WORLD_TASK = "default"; - const WAKE_MAX_ENTRIES = 50; - const WAKE_KINDS = /* @__PURE__ */ new Set([ - "question", - "blocker", - "handoff" - ]); - const KIND_RANK = { - question: 0, - blocker: 1, - handoff: 2 - }; - const BROADCAST_PREFIX = "[NMG board 协作广播]"; - const WORLD_BROADCAST_SESSION = "world-broadcast"; - const BROADCAST_KINDS = /* @__PURE__ */ new Set([ - "question", - "blocker", - "handoff" - ]); - const BROADCAST_TTL_SECONDS = 86400; - const WAKE_INTERVAL_MS = clampInt(process.env.NMG_BOARD_WAKE_INTERVAL_SEC, 5, 3600, 30) * 1e3; - const wakeBatch = /* @__PURE__ */ new Map(); - let wakeAgentRegistered = false; - let wakeConfig = null; - const lastAgents = /* @__PURE__ */ new Map(); - function wakeEntryKey(entry) { - return entry && entry.id ? String(entry.id) : ""; - } - function wakeEntryLine(entry) { - return "#" + entry.sequence + " " + entry.id + " [" + entry.kind + "/" + entry.status + "] " + (entry.agentId || "?") + ": " + truncate(entry.content, 200); - } - function loadWakeConfig() { - try { - const home = process.env.USERPROFILE || process.env.HOME || ""; - const dataDir = (process.env.NMG_DATA_DIR || join(home, ".nmg")).replace(/[\\/]+$/, ""); - wakeConfig = JSON.parse(readFileSync(join(dataDir, "board-wake.json"), "utf8")); - } catch { - wakeConfig = null; - } - } - function pushWakeEntry(entry, targetSessionId) { - if (!entry || !targetSessionId) return; - const key = wakeEntryKey(entry); - if (!key) return; - const existing = wakeBatch.get(targetSessionId) || []; - if (existing.some((e) => wakeEntryKey(e) === key)) return; - wakeBatch.set(targetSessionId, [entry].concat(existing).slice(0, WAKE_MAX_ENTRIES)); - } - function isWakeCandidate(entry, extraTargets) { - const now = Date.now(); - const liveClaim = entry.claimExpiresAt != null && new Date(entry.claimExpiresAt).getTime() > now; - const addressedToOther = entry.to != null && entry.to !== WAKE_AGENT_ID && entry.to !== WAKE_AGENT_NAME && !(extraTargets && extraTargets.has(entry.to)); - const serialQueued = entry.serialState === "pending"; - return entry.status === "open" && WAKE_KINDS.has(entry.kind) && !liveClaim && !addressedToOther && !serialQueued && !String(entry.content || "").startsWith(BROADCAST_PREFIX); - } - function removeWakeEntry(entryId) { - for (const [key, list] of wakeBatch) { - const next = list.filter((entry) => wakeEntryKey(entry) !== String(entryId)); - if (next.length) wakeBatch.set(key, next); - else wakeBatch.delete(key); - } - } - async function maybeBroadcastToWorld(entry, agentId, sessionId) { - if (!entry || String(entry.content || "").startsWith(BROADCAST_PREFIX)) return false; - if (!BROADCAST_KINDS.has(entry.kind)) return false; - const worldCheck = await daemonCall("taskBoard", { - action: "deliveryCheck", - taskId: WAKE_WORLD_TASK, - agentId, - sessionId: WORLD_BROADCAST_SESSION, - entryIds: [wakeEntryKey(entry)] - }); - if (worldCheck && Array.isArray(worldCheck.delivered) && worldCheck.delivered.includes(wakeEntryKey(entry))) return false; - const excerpt = String(entry.content || "").length > 140 ? String(entry.content || "").slice(0, 140) + "…" : String(entry.content || ""); - const label = entry.kind === "question" ? "问题" : entry.kind === "blocker" ? "阻塞" : "交接"; - const broadcast = "[NMG board 协作广播] 频道 " + (entry.taskId || "?") + " 有 #" + entry.sequence + " 未认领的" + label + "(open):" + excerpt + "。有空的 agent 可用 nmg_board read taskId=" + (entry.taskId || "?") + " 查看详情、claim 认领处理。"; - await daemonCall("taskBoard", { - action: "put", - taskId: WAKE_WORLD_TASK, - agentId, - sourceSessionId: sessionId, - kind: "handoff", - content: broadcast, - ttlSeconds: BROADCAST_TTL_SECONDS - }); - await daemonCall("taskBoard", { - action: "recordDelivery", - entryId: wakeEntryKey(entry), - sessionId: WORLD_BROADCAST_SESSION, - agentId, - source: "wake-broadcast" - }); - return true; - } - async function boardWakeOnce() { - if (!await ensureDaemon()) { - wakeAgentRegistered = false; - return; - } - try { - loadWakeConfig(); - if (wakeConfig && wakeConfig.enabled === false) return; - if (!wakeAgentRegistered) { - await daemonCall("taskBoard", { - action: "registerAgent", - id: WAKE_AGENT_ID, - agentName: WAKE_AGENT_NAME, - description: "DSH NMG adapter (NodeMemoryGraph host package)", - capabilities: "dsh-nmg", - supportedInterfaces: "dsh-harness" - }); - wakeAgentRegistered = true; - } - await daemonCall("taskBoard", { - action: "heartbeat", - id: WAKE_AGENT_ID - }); - const candidates = []; - const seen = /* @__PURE__ */ new Set(); - const collect = (taskId, entries) => { - for (const entry of entries || []) { - const key = wakeEntryKey(entry); - if (!key || seen.has(key)) continue; - seen.add(key); - candidates.push(taskId == null ? entry : { - ...entry, - taskId - }); - } - }; - const agentsService = ctx.get("agents"); - if (agentsService && typeof agentsService.list === "function") { - for (const agent of agentsService.list()) if (agent && agent.id) lastAgents.set(String(agent.id), agent); - } - const subagents = ctx.get("subagents"); - const childTargets = /* @__PURE__ */ new Set(); - const childMap = /* @__PURE__ */ new Map(); - if (subagents && typeof subagents.listChildren === "function") for (const [parentSessionId, parent] of lastAgents) { - if (!parent) continue; - let children; - try { - children = await subagents.listChildren(parentSessionId); - } catch { - continue; - } - for (const child of children || []) { - if (!child || child.kind !== "child" || child.mode !== "continuable") continue; - const childId = String(child.id); - childTargets.add(childId); - childMap.set(childId, parent); - const childDirected = await daemonCall("taskBoard", { - action: "readDirected", - agentId: childId, - agentName: childId, - limit: WAKE_MAX_ENTRIES - }); - collect(null, childDirected && childDirected.entries); - } - } - const directed = await daemonCall("taskBoard", { - action: "readDirected", - agentId: WAKE_AGENT_ID, - agentName: WAKE_AGENT_NAME, - limit: WAKE_MAX_ENTRIES - }); - collect(null, directed && directed.entries); - const world = await daemonCall("taskBoard", { - action: "read", - taskId: WAKE_WORLD_TASK, - agentId: WAKE_AGENT_ID, - limit: WAKE_MAX_ENTRIES - }); - collect(WAKE_WORLD_TASK, world && world.entries); - const subs = await daemonCall("taskBoard", { - action: "listSubscriptions", - agentId: WAKE_AGENT_ID, - sessionId: hostSessionId - }); - for (const board of subs && Array.isArray(subs.subscriptions) && subs.subscriptions || []) { - if (!board.taskId || board.taskId === WAKE_WORLD_TASK) continue; - const read = await daemonCall("taskBoard", { - action: "read", - taskId: board.taskId, - agentId: WAKE_AGENT_ID, - limit: WAKE_MAX_ENTRIES - }); - collect(board.taskId, read && read.entries); - } - const mine = candidates.filter((entry) => isWakeCandidate(entry, childTargets)); - if (wakeConfig && wakeConfig.worldBroadcast) for (const entry of mine) try { - await maybeBroadcastToWorld(entry, WAKE_AGENT_ID, hostSessionId); - } catch {} - if (mine.length === 0) return; - for (const [agentSessionId, agent] of lastAgents) { - if (!agent || typeof agent.send !== "function") continue; - const theirs = mine.filter((entry) => { - if (childTargets.has(String(entry.to || ""))) return false; - return !(entry.sourceSessionId === agentSessionId && entry.agentId === WAKE_AGENT_ID || entry.sourceSessionId === agentSessionId || entry.sourceSessionId == null && entry.agentId === WAKE_AGENT_ID); - }); - if (theirs.length === 0) continue; - const fresh = []; - for (const taskId of new Set(theirs.map((c) => c.taskId))) { - const group = theirs.filter((c) => c.taskId === taskId); - const check = await daemonCall("taskBoard", { - action: "deliveryCheck", - agentId: WAKE_AGENT_ID, - sessionId: agentSessionId, - taskId: taskId || WAKE_WORLD_TASK, - entryIds: group.map(wakeEntryKey) - }); - if (check && check.suppressed) continue; - const delivered = new Set(check && Array.isArray(check.delivered) && check.delivered || []); - const acked = new Set(check && Array.isArray(check.acked) && check.acked || []); - for (const entry of group) if (!delivered.has(wakeEntryKey(entry)) && !acked.has(wakeEntryKey(entry))) fresh.push(entry); - } - if (fresh.length === 0) continue; - fresh.sort((left, right) => (KIND_RANK[left.kind] ?? 9) - (KIND_RANK[right.kind] ?? 9) || String(left.createdAt || "").localeCompare(String(right.createdAt || ""))); - const pick = fresh[0]; - pushWakeEntry(pick, agentSessionId); - if (wakeAgent(agent, pick)) await daemonCall("taskBoard", { - action: "recordDelivery", - agentId: WAKE_AGENT_ID, - sessionId: agentSessionId, - entryId: wakeEntryKey(pick), - source: "wake" - }); - } - for (const [childId, parent] of childMap) { - const theirs = mine.filter((entry) => String(entry.to || "") === childId && entry.sourceSessionId !== childId); - if (theirs.length === 0) continue; - const fresh = []; - for (const taskId of new Set(theirs.map((c) => c.taskId))) { - const group = theirs.filter((c) => c.taskId === taskId); - const check = await daemonCall("taskBoard", { - action: "deliveryCheck", - agentId: childId, - sessionId: childId, - taskId: taskId || WAKE_WORLD_TASK, - entryIds: group.map(wakeEntryKey) - }); - if (check && check.suppressed) continue; - const delivered = new Set(check && Array.isArray(check.delivered) && check.delivered || []); - const acked = new Set(check && Array.isArray(check.acked) && check.acked || []); - for (const entry of group) if (!delivered.has(wakeEntryKey(entry)) && !acked.has(wakeEntryKey(entry))) fresh.push(entry); - } - if (fresh.length === 0) continue; - fresh.sort((left, right) => (KIND_RANK[left.kind] ?? 9) - (KIND_RANK[right.kind] ?? 9) || String(left.createdAt || "").localeCompare(String(right.createdAt || ""))); - const pick = fresh[0]; - try { - await subagents.followup(parent, childId, [{ - type: "text", - text: wakeMessageText(pick) - }], { - source: { - kind: "plugin", - plugin: "@nmg/dsh-nmg" - }, - signal: AbortSignal.timeout(5e3) - }); - } catch { - continue; - } - await daemonCall("taskBoard", { - action: "recordDelivery", - agentId: childId, - sessionId: childId, - entryId: wakeEntryKey(pick), - source: "wake" - }); - pushWakeEntry(pick, childId); - } - } catch { - wakeAgentRegistered = false; - } - } - function wakeMessageText(entry) { - return "[NMG board] 新黑板条目 #" + entry.sequence + " [" + entry.kind + "] " + (entry.taskId || "?") + " by " + (entry.agentId || "?") + ":\n" + truncate(entry.content, 400); - } - function wakeAgent(agent, entry) { - if (!agent || typeof agent.send !== "function") return false; - try { - agent.send({ - id: randomUUID(), - role: "user", - content: [{ - type: "text", - text: wakeMessageText(entry) - }], - source: { - kind: "plugin", - plugin: "@nmg/dsh-nmg" - } - }, "next-turn", true); - return true; - } catch { - return false; - } - } - function wakeTextFor(agent) { - if (!agent) return ""; - try { - const batch = wakeBatch.get(String(agent.id)); - if (!Array.isArray(batch) || batch.length === 0) return ""; - const lines = batch.map(wakeEntryLine); - lines.push("Claim with nmg_board claim (claim=接手), resolve with nmg_board resolve."); - return "NMG board wake (" + batch.length + " pending):\n" + lines.join("\n"); - } catch { - return ""; - } - } - function wakeDataFor(targetSessionId) { - if (!targetSessionId) return null; - try { - const batch = wakeBatch.get(String(targetSessionId)); - if (!Array.isArray(batch) || batch.length === 0) return null; - return { entries: batch.map((entry) => ({ - id: entry.id, - sequence: entry.sequence, - taskId: entry.taskId, - kind: entry.kind, - status: entry.status, - agentId: entry.agentId, - claimedBy: entry.claimedBy || null, - content: entry.content - })) }; - } catch { - return null; - } - } - const searchTool = { - name: "nmg_search", - description: nmgPrompts.search_description, - parameters: { - type: "object", - properties: { - query: { - type: "string", - description: "Focused recall query." - }, - limit: { - type: "integer", - description: "Return 1..50 records (default 8)." - }, - maxTier: { - type: "integer", - description: "Deepest memory tier 0..3." - }, - graphHops: { - type: "integer", - description: "Graph expansion 0..3." - }, - nodeName: { - type: "string", - description: "Restrict to one semantic node." - }, - sourceActor: { - type: "string", - enum: [ - "user", - "assistant", - "system", - "tool" - ], - description: "Restrict evidence actor." - }, - includeHistorical: { - type: "boolean", - description: "Include inactive/superseded memories." - }, - scope: { - type: "object", - additionalProperties: true, - description: "Applicability scope, e.g. {\"project\":\"nmg\"}." - } - }, - required: ["query"] - }, - output: textOutput, - async execute(args, exec) { - const params = { - query: args.query, - projectDir: workspaceRoot - }; - if (args.limit != null) params.limit = args.limit; - if (args.maxTier != null) params.maxTier = args.maxTier; - if (args.graphHops != null) params.graphHops = args.graphHops; - if (args.nodeName) params.nodeName = args.nodeName; - if (args.sourceActor) params.sourceActor = args.sourceActor; - if (args.includeHistorical) params.includeHistorical = true; - if (args.scope) params.scope = coerceScope(args.scope); - const argv = ["search", args.query]; - if (args.limit != null) argv.push("--limit", String(args.limit)); - if (args.maxTier != null) argv.push("--max-tier", String(args.maxTier)); - if (args.graphHops != null) argv.push("--graph-hops", String(args.graphHops)); - if (args.nodeName) argv.push("--node", args.nodeName); - if (args.sourceActor) argv.push("--source-actor", args.sourceActor); - if (args.includeHistorical) argv.push("--include-historical"); - for (const pair of scopeArgs(args.scope)) argv.push(pair[0], pair[1]); - argv.push("--project-dir", workspaceRoot, "--json"); - const r = await invoke("search", params, argv, exec.signal, null); - if (!r.ok) return r.error; - const data = r.data; - const deferred = data.progressiveDisclosure && data.progressiveDisclosure.deferredMemoryIds; - const nextStep = Array.isArray(deferred) && deferred.length ? "More ranked records are folded. Expand selected memory IDs first; deferred IDs: " + deferred.join(",") : "Select exact records with nmg_get (memory IDs + activeGraphId)."; - const forget = data.results.some((result) => (result.memory.markers || []).some((marker) => marker.kind === "forget")); - return renderSearchSurface(data, { - preamble: renderDisclosure(nmgPrompts.search_disclosure, {}), - postamble: renderDisclosure(nmgPrompts.search_disclosure_metadata, { - count: String(data.results.length), - next_step: nextStep, - forget_hint: forget ? nmgPrompts.forget_hint : "" - }) - }); - } - }; - const getTool = { - name: "nmg_get", - description: nmgPrompts.get_description, - parameters: { - type: "object", - properties: { - memoryIds: { - type: "array", - items: { type: "string" }, - description: "Memory IDs from nmg_search." - }, - activeGraphId: { - type: "string", - description: "activeGraphId returned by the matching nmg_search." - }, - graphHops: { - type: "integer", - description: "Graph expansion 0..3." - } - }, - required: ["memoryIds"] - }, - output: textOutput, - async execute(args, exec) { - const ids = Array.isArray(args.memoryIds) ? args.memoryIds : []; - if (ids.length === 0) return "nmg_get requires at least one memory ID."; - const params = { - memoryIds: ids, - projectDir: workspaceRoot - }; - if (args.activeGraphId) params.activeGraphId = args.activeGraphId; - if (args.graphHops != null) params.graphHops = args.graphHops; - const argv = ["get"].concat(ids); - if (args.activeGraphId) argv.push("--active-graph-id", args.activeGraphId); - if (args.graphHops != null) argv.push("--graph-hops", String(args.graphHops)); - argv.push("--project-dir", workspaceRoot, "--json"); - const r = await invoke("get", params, argv, exec.signal, null); - if (!r.ok) return r.error; - const forget = r.data.results.some((result) => (result.memory.markers || []).some((marker) => marker.kind === "forget")); - return renderEvidenceSurface(r.data, { - preamble: renderDisclosure(nmgPrompts.get_disclosure, {}), - postamble: renderDisclosure(nmgPrompts.get_disclosure_metadata, { - count: String(r.data.results.length), - next_step: "", - forget_hint: forget ? nmgPrompts.forget_hint : "" - }), - missingMemoryIds: Array.isArray(r.data.missingMemoryIds) ? r.data.missingMemoryIds : void 0 - }); - } - }; - const rememberTool = { - name: "nmg_remember", - description: "Save or update durable memory through the shared NMG lifecycle contract. Never save secrets, chatter, unverified model claims, or transient failures.", - parameters: { - type: "object", - properties: { - action: { - type: "string", - enum: [...COMMON_REMEMBER_ACTIONS], - description: "Memory action (default save)." - }, - memoryId: { - type: "string", - description: "Existing memory for forget/resolve/reopen/claim_outcome." - }, - newMemoryId: { - type: "string", - description: "Newer memory for supersede/relate." - }, - supersededMemoryId: { - type: "string", - description: "Older memory replaced by newMemoryId." - }, - relatedMemoryId: { - type: "string", - description: "Existing memory related to newMemoryId." - }, - relatedMemoryIds: { - type: "array", - items: { type: "string" }, - description: "Evidence anchors for resolve/reopen." - }, - relationJudgement: { - type: "string", - enum: [ - "conflict", - "distinct", - "refines", - "related", - "same_entity" - ] - }, - relationConfidence: { - type: "number", - description: "Relation confidence 0..1." - }, - resolutionReason: { - type: "string", - description: "Reason for supersede/resolve/reopen." - }, - semanticTaskId: { - type: "string", - description: "Independent task identity for claim_outcome." - }, - activeGraphId: { - type: "string", - description: "Active graph that produced the evaluated claim." - }, - claimOutcome: { - type: "string", - enum: ["supported", "contradicted"] - }, - claimSourceLineage: { - type: "string", - description: "Stable attributable source lineage." - }, - claimIndexes: { - type: "array", - items: { type: "integer" } - }, - claimWeight: { - type: "number", - description: "Claim reliability in (0,1]." - }, - statement: { - type: "string", - description: "Self-contained semantic statement." - }, - nodeName: { - type: "string", - description: "Stable node grouping related memories." - }, - memoryType: { - type: "string", - enum: [ - "fact", - "state", - "event", - "preference", - "constraint", - "strategy" - ], - description: "Memory type." - }, - recallTriggers: { - type: "array", - maxItems: 16, - items: { - type: "string", - minLength: 1, - maxLength: 80 - }, - description: nmgPrompts.recall_triggers_parameter_description - }, - stateKey: { - type: "string", - description: "Replaceable property identity; a new value in the same scope supersedes the old." - }, - sourceActor: { - type: "string", - enum: [ - "user", - "assistant", - "system", - "tool" - ], - description: "Evidence actor (default user)." - }, - truthStatus: { - type: "string", - enum: [ - "asserted", - "inferred", - "unverified", - "verified" - ], - description: "Truth status." - }, - evidence: { - type: "string", - description: "Exact supporting source excerpt." - }, - eventTime: { - type: "string", - description: "ISO event time when it differs from write time." - }, - tier: { - type: "integer", - description: "Initial tier 0..3." - }, - importance: { - type: "number", - description: "Importance 0..1." - }, - residence: { - type: "string", - enum: ["ltg", "stg"], - description: "ltg (durable) or stg (session/task-local)." - }, - writeReason: { - type: "string", - description: "Durable-write justification." - }, - scope: { - type: "object", - additionalProperties: true, - description: "Applicability scope, e.g. {\"project\":\"nmg\"}." - } - }, - required: [] - }, - output: textOutput, - async execute(args, exec) { - const action = args.action || "save"; - const sessionId = exec && exec.agent && exec.agent.id ? String(exec.agent.id) : hostSessionId; - if (action === "claim_outcome") { - if (!args.memoryId || !args.claimOutcome || !args.semanticTaskId || !args.claimSourceLineage) return "nmg_remember claim_outcome requires memoryId, claimOutcome, semanticTaskId, and claimSourceLineage."; - const result = await invokeRpcOnly("recordClaimOutcomes", { - semanticTaskId: args.semanticTaskId, - activeGraphId: args.activeGraphId, - sessionId, - collectionOrigin: "natural", - projectDir: workspaceRoot, - votes: [{ - memoryId: args.memoryId, - claimIndexes: args.claimIndexes, - outcome: args.claimOutcome, - source: "task", - sourceLineage: args.claimSourceLineage, - weight: args.claimWeight - }] - }, exec.signal); - return result.ok ? JSON.stringify(result.data) : result.error; - } - if (action !== "save") { - const params = { - action, - projectDir: workspaceRoot, - sessionId - }; - if (args.memoryId) params.memoryId = args.memoryId; - if (args.newMemoryId) params.newMemoryId = args.newMemoryId; - if (args.supersededMemoryId) params.supersededMemoryId = args.supersededMemoryId; - if (args.relatedMemoryId) params.relatedMemoryId = args.relatedMemoryId; - if (args.relatedMemoryIds) params.relatedMemoryIds = args.relatedMemoryIds; - if (args.relationJudgement) params.relationJudgement = args.relationJudgement; - if (args.relationConfidence != null) params.confidence = args.relationConfidence; - if (args.resolutionReason) params.reason = args.resolutionReason; - const result = await invokeRpcOnly("resolveRemember", params, exec.signal); - return result.ok ? JSON.stringify(result.data) : result.error; - } - if (!args.statement || !args.nodeName) return "nmg_remember save requires statement and nodeName."; - const params = { - statement: args.statement, - nodeName: args.nodeName, - projectDir: workspaceRoot - }; - if (args.memoryType) params.memoryType = args.memoryType; - if (args.recallTriggers) params.recallTriggers = args.recallTriggers; - if (args.stateKey) params.stateKey = args.stateKey; - if (args.sourceActor) params.sourceActor = args.sourceActor; - if (args.truthStatus) params.truthStatus = args.truthStatus; - if (args.evidence) params.evidence = args.evidence; - if (args.eventTime) params.eventTime = args.eventTime; - if (args.tier != null) params.tier = args.tier; - if (args.importance != null) params.importance = args.importance; - if (args.residence) params.residence = args.residence; - if (args.writeReason) params.writeReason = args.writeReason; - if (args.scope) params.scope = coerceScope(args.scope); - const argv = [ - "remember", - args.statement, - "--node", - args.nodeName - ]; - if (args.memoryType) argv.push("--type", args.memoryType); - for (const trigger of args.recallTriggers || []) argv.push("--recall-trigger", trigger); - if (args.stateKey) argv.push("--state-key", args.stateKey); - if (args.sourceActor) argv.push("--actor", args.sourceActor); - if (args.truthStatus) argv.push("--truth", args.truthStatus); - if (args.evidence) argv.push("--evidence", args.evidence); - if (args.eventTime) argv.push("--event-time", args.eventTime); - if (args.tier != null) argv.push("--tier", String(args.tier)); - if (args.importance != null) argv.push("--importance", String(args.importance)); - if (args.residence) argv.push("--residence", args.residence); - if (args.writeReason) argv.push("--write-reason", args.writeReason); - for (const pair of scopeArgs(args.scope)) argv.push(pair[0], pair[1]); - argv.push("--project-dir", workspaceRoot, "--json"); - const r = await invoke("remember", params, argv, exec.signal, null); - if (!r.ok) return r.error; - return renderRememberSurface(r.data); - } - }; - const boardTool = { - name: "nmg_board", - description: "Temporary, task-scoped cross-agent coordination (not durable memory). Entries expire. Use a stable taskId; default agent identity is \"dsh\". Promote durable conclusions only through a separate nmg_remember.", - parameters: { - type: "object", - properties: { - action: { - type: "string", - enum: [...COMMON_BOARD_ACTIONS], - description: "Board action." - }, - taskId: { - type: "string", - description: "Task channel; omit for the shared world channel." - }, - content: { - type: "string", - description: "Entry text (put)." - }, - kind: { - type: "string", - enum: [ - "goal", - "note", - "question", - "result", - "handoff", - "decision", - "blocker" - ], - description: "Entry kind (put)." - }, - agentId: { - type: "string", - description: "Writer/reader identity (default \"dsh\")." - }, - entryId: { - type: "string", - description: "Entry to resolve/claim/release." - }, - resolution: { - type: "string", - description: "Resolution note (resolve)." - }, - reason: { - type: "string", - description: "Reason for acknowledge/subscribe/unsubscribe." - }, - afterCursor: { - type: "integer", - description: "Read only entries after this sequence (read)." - }, - limit: { - type: "integer", - description: "Max entries (read)." - }, - includeResolved: { - type: "boolean", - description: "Include resolved entries (read)." - }, - ttlSeconds: { - type: "integer", - description: "Entry lifetime 60..2592000 (put)." - }, - to: { - type: "string", - description: "Directed delivery to a stable agent name (put)." - }, - leaseSeconds: { - type: "integer", - description: "Claim lease 60..86400 (claim)." - }, - capabilities: { - type: "string", - description: "Capability substring filter (discover)." - } - }, - required: ["action"] - }, - output: textOutput, - async execute(args, exec) { - const agent = args.agentId || WAKE_AGENT_ID; - const sourceSessionId = exec && exec.agent && exec.agent.id ? String(exec.agent.id) : hostSessionId; - const taskId = args.taskId || WAKE_WORLD_TASK; - if ([ - "resolve", - "acknowledge", - "claim", - "release" - ].includes(args.action) && (!args.taskId || !args.entryId)) return "nmg_board " + args.action + " requires taskId and entryId."; - if (!COMMON_BOARD_ACTIONS.includes(args.action)) return "Unsupported board action: " + args.action; - const params = { - action: args.action, - agentId: agent - }; - const argv = ["board", args.action]; - if (args.action === "subscribe" || args.action === "unsubscribe") { - const result = await invokeRpcOnly("taskBoard", { - action: args.action, - taskId, - sessionId: sourceSessionId, - agentId: agent, - reason: args.reason - }, exec.signal); - return result.ok ? JSON.stringify(result.data) : result.error; - } else if (args.action === "acknowledge") { - const result = await invokeRpcOnly("taskBoard", { - action: args.action, - taskId, - entryId: args.entryId, - agentId: agent, - sourceSessionId, - reason: args.reason - }, exec.signal); - return result.ok ? JSON.stringify(result.data) : result.error; - } else if (args.action === "discover") { - params.taskId = "default"; - if (args.capabilities) params.capabilities = args.capabilities; - argv.push("--agent", agent); - if (args.capabilities) argv.push("--capabilities", args.capabilities); - } else if (args.action === "put") { - params.taskId = taskId; - params.content = args.content || ""; - if (args.kind) params.kind = args.kind; - if (args.to) params.to = args.to; - if (args.ttlSeconds != null) params.ttlSeconds = args.ttlSeconds; - params.sourceSessionId = sourceSessionId; - argv.push(taskId, args.content || ""); - argv.push("--agent", agent); - argv.push("--session-id", sourceSessionId); - if (args.kind) argv.push("--kind", args.kind); - if (args.to) argv.push("--to", args.to); - if (args.ttlSeconds != null) argv.push("--ttl-seconds", String(args.ttlSeconds)); - } else if (args.action === "read") { - params.taskId = taskId; - if (args.afterCursor != null) params.afterCursor = args.afterCursor; - if (args.limit != null) params.limit = args.limit; - if (args.includeResolved) params.includeResolved = true; - argv.push(taskId, "--agent", agent); - if (args.afterCursor != null) argv.push("--after-cursor", String(args.afterCursor)); - if (args.limit != null) argv.push("--limit", String(args.limit)); - if (args.includeResolved) argv.push("--include-resolved"); - } else { - params.taskId = taskId; - params.entryId = args.entryId; - if (args.action === "resolve" && args.resolution) params.resolution = args.resolution; - if (args.action === "claim" && args.leaseSeconds != null) params.leaseSeconds = args.leaseSeconds; - argv.push(taskId, args.entryId, "--agent", agent); - if (args.action === "resolve" && args.resolution) argv.push("--resolution", args.resolution); - if (args.action === "claim" && args.leaseSeconds != null) argv.push("--lease-seconds", String(args.leaseSeconds)); - } - argv.push("--json"); - const r = await invoke("taskBoard", params, argv, exec.signal, null); - if (!r.ok) return r.error; - if (args.entryId && (args.action === "claim" || args.action === "resolve")) removeWakeEntry(args.entryId); - return renderTaskBoardSurface(r.data, { taskId }); - } - }; - const daemonTool = { - name: "nmg_daemon", - description: "Read-only NMG daemon health check. The adapter may ensure a daemon is running for requests, but it does not expose lifecycle ownership or stop it on plugin disposal.", - parameters: { - type: "object", - properties: { action: { - type: "string", - enum: ["status"], - description: "Health check." - } }, - required: ["action"] - }, - output: textOutput, - async execute(args, exec) { - const r = await invoke("status", {}, [ - "daemon", - args.action, - "--json" - ], exec.signal, projectDaemonStatus); - if (!r.ok) return r.error; - const data = r.data; - if (args.action === "status") { - if (!data.running) return "NMG daemon: not running (one-shot CLI still works in-process)."; - return "NMG daemon: running pid=" + data.pid + " endpoint=" + data.endpoint + " compatible=" + data.compatible; - } - return JSON.stringify(data); - } - }; - const labTool = { - name: "nmg_lab", - description: "Discover and temporarily enable optional NMG capabilities for this session. Reasoning workspace, graph reasoner, and controller shadow are self-service; controlled/active controller modes remain gated.", - parameters: { - type: "object", - properties: { - action: { - type: "string", - enum: [ - "list", - "status", - "enable", - "disable", - "invoke" - ] - }, - capability: { - type: "string", - enum: [ - "reasoning_workspace", - "memory_graph_reasoner", - "controller_shadow", - "controller_controlled", - "controller_active" - ] - }, - reason: { type: "string" }, - ttlSeconds: { type: "integer" }, - operation: { type: "string" }, - input: { - type: "object", - additionalProperties: true - } - }, - required: ["action"] - }, - output: textOutput, - async execute(args, exec) { - const sessionId = exec && exec.agent && exec.agent.id ? String(exec.agent.id) : hostSessionId; - if (args.action !== "list" && !args.capability) return args.action + " requires capability."; - if (args.action === "enable" && !args.reason) return "enable requires reason."; - if (args.action === "invoke" && !args.operation) return "invoke requires operation."; - const params = { - action: args.action, - capability: args.capability, - sessionId, - requester: args.action === "enable" ? "agent:dsh" : void 0, - reason: args.reason, - ttlSeconds: args.ttlSeconds, - operation: args.operation, - input: args.input - }; - const argv = ["lab", args.action]; - if (args.capability) argv.push(args.capability); - if (args.action !== "list") argv.push("--session-id", sessionId); - if (args.action === "enable") { - argv.push("--requester", "agent:dsh", "--reason", args.reason); - if (args.ttlSeconds != null) argv.push("--ttl-seconds", String(args.ttlSeconds)); - } - if (args.action === "invoke") { - argv.push("--operation", args.operation); - if (args.input != null) argv.push("--input-json", JSON.stringify(args.input)); - } - argv.push("--json"); - const r = await invoke("lab", params, argv, exec.signal, null); - if (!r.ok) return r.error; - return JSON.stringify(r.data, null, 2); - } - }; - const contextDisposer = systemPrompt.context({ - name: "nmg:recall", - order: 90, - text: (assembleContext) => recallTextFor(assembleContext && assembleContext.agent) - }); - const boardWakeContextDisposer = coordinationEnabled$1 ? systemPrompt.context({ - name: "nmg:board-wake", - order: 85, - text: (assembleContext) => wakeTextFor(assembleContext && assembleContext.agent) - }) : void 0; - const ROUTE_PATH = "/nmg/recall"; - let routeRegistered = false; - let routeDisposer = void 0; - function tryRegisterRoute(server) { - if (routeRegistered || !server || typeof server.register !== "function") return; - const dispose = server.register({ - kind: "prefix", - path: ROUTE_PATH, - handler(req, res) { - try { - const url = req.url || "/nmg/recall"; - const data = recallDataFor(new URL(url, "http://x").searchParams.get("session") || ""); - res.writeHead(200, { - "content-type": "application/json; charset=utf-8", - "cache-control": "no-store" - }); - res.end(JSON.stringify({ - ok: true, - data - })); - } catch (error) { - res.writeHead(500, { "content-type": "application/json; charset=utf-8" }); - res.end(JSON.stringify({ - ok: false, - error: error && error.message ? String(error.message) : String(error) - })); - } - } - }); - routeDisposer = typeof dispose === "function" ? dispose : void 0; - routeRegistered = true; - } - tryRegisterRoute((() => { - try { - return ctx.reflect.get("webServer", false); - } catch { - return; - } - })()); - ctx.on("internal/service", (name, value) => { - if (name === "webServer") tryRegisterRoute(value); - }); - const WAKE_ROUTE_PATH = "/nmg/board-wake"; - let wakeRouteRegistered = false; - let wakeRouteDisposer = void 0; - function tryRegisterWakeRoute(server) { - if (wakeRouteRegistered || !server || typeof server.register !== "function") return; - const dispose = server.register({ - kind: "prefix", - path: WAKE_ROUTE_PATH, - handler(req, res) { - try { - const url = req.url || WAKE_ROUTE_PATH; - const data = wakeDataFor(new URL(url, "http://x").searchParams.get("session") || ""); - res.writeHead(200, { - "content-type": "application/json; charset=utf-8", - "cache-control": "no-store" - }); - res.end(JSON.stringify({ - ok: true, - data - })); - } catch (error) { - res.writeHead(500, { "content-type": "application/json; charset=utf-8" }); - res.end(JSON.stringify({ - ok: false, - error: error && error.message ? String(error.message) : String(error) - })); - } - } - }); - wakeRouteDisposer = typeof dispose === "function" ? dispose : void 0; - wakeRouteRegistered = true; - } - if (coordinationEnabled$1) tryRegisterWakeRoute((() => { - try { - return ctx.reflect.get("webServer", false); - } catch { - return; - } - })()); - const wakeServiceDisposer = coordinationEnabled$1 ? ctx.on("internal/service", (name, value) => { - if (name === "webServer") tryRegisterWakeRoute(value); - }) : void 0; - const wakeTimerDisposer = coordinationEnabled$1 ? ctx.interval(boardWakeOnce, WAKE_INTERVAL_MS) : void 0; - const initialWakeDisposer = coordinationEnabled$1 ? ctx.timeout(boardWakeOnce, 0) : void 0; - const disposers = [ - tools.register(searchTool), - tools.register(getTool), - tools.register(rememberTool), - tools.register(labTool), - tools.register(daemonTool), - contextDisposer, - ctx.on("agent/inbox/inserted", onInboxInserted), - ctx.on("agent/disposed", onAgentDisposed) - ]; - if (coordinationEnabled$1) disposers.push(tools.register(boardTool), boardWakeContextDisposer, wakeServiceDisposer, wakeTimerDisposer, initialWakeDisposer); - if (routeDisposer) disposers.push(routeDisposer); - if (wakeRouteDisposer) disposers.push(wakeRouteDisposer); - return () => { - for (const dispose of disposers) if (typeof dispose === "function") dispose(); - recallWindows.clear(); - sessionTokenTotals.clear(); - recallBatch.clear(); - openSearches.clear(); - wakeBatch.clear(); - }; -} -//#endregion -export { apply, inject }; diff --git a/package-lock.json b/package-lock.json index 6fd763d..7e2aa9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "yaml": "^2.9.0" }, "bin": { - "nmg": "bin/nmg.mjs" + "nmg": "bin/nmg.mjs", + "nmg-rcp": "bin/nmg-rcp.mjs" }, "devDependencies": { "@deepseek-ai/cordis": "4.0.1", diff --git a/src/prompts/nmg-prompts.generated.ts b/src/prompts/nmg-prompts.generated.ts deleted file mode 100644 index 712cb90..0000000 --- a/src/prompts/nmg-prompts.generated.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Generated from nmg-prompts.yaml by scripts/generate-prompts.ts. Do not edit. -export const GENERATED_NMG_PROMPTS = { - search_description: - 'Search long-term memory. Queries match saved records by literal terms, semantic similarity, and learned graph routes; returns ranked candidate headers (memory id, node, type, match terms, event date, preview) plus an activeGraphId. Exact records and source evidence are loaded via nmg_get. The store only contains what was explicitly saved - it is not a live source. Advanced syntax: key:value filters (type:preference,constraint node:"Conversation ..." state:key time:2026-01-01..2026-06-30), -term exclusions, and quoted phrases.', - get_description: - "Load exact memory records and source evidence for memory IDs returned by nmg_search (or nmg_automatic_recall). When available, pass the activeGraphId returned by search so NMG can attribute actual use; it is not required to read a known memory ID. Returns the full statement plus evidence content and source reference.", - remember_description: - "Save a durable record (fact, state, event, preference, constraint, strategy), resolve a possible supersession, attach explicit evaluation feedback to a retrieval trace, or record an attributable supported/contradicted outcome for selected saved claims. You decide the semantic content and whether a candidate is truly an older value; NMG validates scope, identity, provenance, and applies the transaction. The record persists across sessions. Only save attributable information - not secrets, transient content, or unsupported guesses. Never label an Assistant inference as a user statement. A stateKey names one replaceable property within a stable scope; it is not a topic or grouping tag. recallTriggers may name a few short aliases or likely query phrases that should retrieve this record when its factual statement uses different wording.", - mcp_remember_description: - "Save a durable record or apply an explicit memory lifecycle action. Supports save, supersede, relate, forget, resolve, reopen, and task-attributed claim_outcome. The MCP adapter cannot verify user/tool message evidence and does not expose Pi-local retrieval feedback, so claim_outcome accepts only a task source with an explicit source lineage. NMG validates scope, identity, provenance, and applies the transaction.", - board_description: - "Coordinate through a temporary blackboard shared by NMG clients. Without a taskId, entries land on the shared world channel; reading it lists active named channels you can join by name. Entries expire automatically and retain writer attribution (NMG_AGENT_ID). Use put/read/resolve; full conventions (memory pointers, boundaries) are disclosed in each tool result.", - lab_description: - "Discover and temporarily enable optional NMG capabilities for the current session. Use list before first use, enable only when the current task benefits, invoke a documented operation, and disable when finished. Reasoning workspace, graph reasoner, and controller shadow are self-service; controlled or active controller modes remain harness/operator gated. Lab output is provisional and never becomes durable memory unless separately submitted through nmg_remember.", - reason_description: - "Maintain a session-private, auditable reasoning scratchpad containing typed goals, observations, hypotheses, evidence, conclusions, decisions, open questions, next actions, and explicit relations. It persists checkpoints across Pi compaction and process restart but never writes scratch content into durable semantic memory. Evidence nodes require stable source references; supported nodes require either such a reference or an anchored support path. Unsupported hypotheses remain visibly marked as unsupported after compaction.", - reason_action_parameter_description: - "add creates one typed scratch node and idempotently reuses an exact same-kind, same-content node; update is required for semantic change; link records one explicit relation; checkpoint returns a bounded current view; clear deletes only this session's reasoning scratchpad.", - reason_node_id_parameter_description: - "Stable reasoning-node ID returned by an earlier nmg_reason call. Required for update and scoped to the current Pi session.", - reason_kind_parameter_description: - "The role of a new scratch node in the current reasoning process. Required for add; it is not a durable MemoryNode type.", - reason_content_parameter_description: - "Concise scratch content for add, or replacement content for update. Keep uncertainty explicit; scratch content is not verified fact.", - reason_status_parameter_description: - "Epistemic or workflow status of the scratch node. supported means the node has traceable support; rejected preserves a disproven path; resolved and superseded keep lifecycle history.", - reason_importance_parameter_description: - "Session-local checkpoint priority from 0 to 1. It affects bounded disclosure, not truth, durability, or automatic promotion.", - reason_evidence_refs_parameter_description: - "Stable references supporting a new or updated scratch node, such as NMG memory IDs, source-message IDs, file locations, or tool-result identities. Required for evidence nodes and for directly supported nodes; alternatively mark a node supported only after linking an already anchored source with supports or derived_from. References are retained for audit but are not independently verified by this tool.", - reason_source_id_parameter_description: - "Existing reasoning-node ID at the source of a relation. Required for link.", - reason_target_id_parameter_description: - "Existing reasoning-node ID at the target of a relation. Required for link.", - reason_relation_parameter_description: - "Explicit directed relation between two existing scratch nodes. Required for link and scoped to the current Pi session.", - board_action_parameter_description: - "Use put to publish one concise coordination entry, read to fetch entries after an optional cursor, claim to take an open entry as the single working Agent (lease-based; expired claims return to the pool, resolving clears the claim), release to put a claimed entry back, unsubscribe to leave a channel (stop receiving its wake notices; the world channel is your default member channel and can be muted the same way), subscribe to join a channel (topic-based membership — a named channel only wakes its members, never non-members), acknowledge to record that you have seen and accepted an entry and owe no reply (pure state write — no broadcast, no wake, and the acked entry stops re-notifying you), or resolve to close a selected entry. discover lists online agents (A2A roster, by capability) so you can pick a target, then put ... to= for directed delivery — only that agent's LLM is woken, everyone else reads but stays silent (find first via discover, then direct). Un-directed actionable entries (handoff/question/ blocker) are serialised per channel: only one outstanding is pushed at a time; later ones sit 'pending' (read-visible, silent) until the outstanding one is claimed (接手=回复) or resolved, which promotes the next. Directed entries are exempt (parallel-safe). kind decides wake routing: question/blocker/handoff ask for a response and WAKE subscribers; note/result/decision/goal are notify-only and SILENT (they record a fact, owe no reply, and are read on demand — never pushed). If you need an answer or action, use question/handoff; never smuggle a request into a note (a silent note asking for something starves). When a fact is already confirmed, do not post a confirming note — acknowledge it (one ack is enough; everyone confirming one fact is an acknowledgement storm). Confirmations close: once a question/handoff is answered, resolve it — a resolved entry is closed and should not be replied to (reopen only for new substance). Acknowledgement is the lightweight \"确认但不用回\": for a result/note you accept, prefer acknowledge over posting a new reply entry — acknowledgers are visible in read output (✅ acked by ...) and readers check them on demand instead of being broadcast every Ack. Put/read/claim/release/ resolve/acknowledge do not create semantic memory. To persist a board conclusion as durable memory WITHOUT duplicating it: (1) nmg_search first — if the fact already exists as a memory, do not\n remember it again; point the board entry at it with memory=.\n(2) only if no existing memory covers it, nmg_remember with\n boardSource: {taskId, entryId} (records a board_origin marker so the\n memory traces back to this board entry and later dedup can recognize\n it). (3) resolve the board entry once the memory is created, noting\n memory= in the resolution.", - remember_board_source_parameter_description: - 'Provenance link from a task board entry to the durable memory being created from its content. When set, nmg_remember attaches a board_origin marker ({kind: "board_origin", attributes: {taskId, entryId}}) so the memory traces back to the board entry and later writes of the same board content can be recognized as duplicates. Use it ONLY when creating a NEW memory from board content that has no existing memory counterpart (search first).', - board_task_id_parameter_description: - "Optional. Name of the channel to read or write. When omitted, entries land on the shared world channel (the default), which every Agent reads; reading the world channel also lists active named channels so you can discover and join one by name.", - board_content_parameter_description: - "Concise task coordination state such as a goal, blocker, result, handoff, question, or decision. Do not include secrets or hidden chain-of-thought. To point other Agents at durable knowledge, include memory= references (the same format automatic recall uses); readers expand them with nmg_get.", - remember_action_parameter_description: - "Omit or use save for a new memory. Use supersede only when a candidate is the older value of the new memory. Use relate to propose a semantic relationship without merging node identity; the proposal remains reversible and pending. Use forget only when the user explicitly asks to withdraw a selected memory. Use resolve when an open question or dependency has been settled. Use reopen when later evidence makes a resolved memory actionable again; provide the related evidence memory IDs so the open structure stays attributable. Use feedback to record explicit task/retrieval labels. Use claim_outcome only after an attributable user, tool, or completed task explicitly supports or contradicts selected saved claims; retrieval, use, silence, and task success alone are not claim outcomes. Pass activeGraphId when labelling a specific older retrieval; omit it only to label the latest retrieval in the current Pi session. Silence or lack of correction is not positive feedback.", - mcp_remember_action_parameter_description: - "Omit or use save for a new memory. Use supersede only when the selected older value has the same scope, relate to create a reversible semantic proposal, forget only for an explicit withdrawal, and resolve or reopen for an open memory lifecycle change. Use claim_outcome only for an independently attributable completed task, with semanticTaskId and claimSourceLineage. The MCP adapter does not accept Pi-local retrieval feedback or user/tool claim evidence because it cannot verify those current-session message sources.", - remember_memory_id_parameter_description: - "For action=forget, resolve, or reopen, the exact target memory ID. This creates an auditable logical deletion, not physical privacy erasure.", - remember_new_memory_id_parameter_description: - "For action=supersede, the new memory ID returned by the preceding save.", - remember_superseded_memory_id_parameter_description: - "For action=supersede, the candidate memory ID whose value is replaced. Do not use this merely because two memories are related or similar.", - remember_related_memory_id_parameter_description: - "For action=relate, a candidate memory ID whose node has a meaningful semantic relationship with the newly saved memory. This does not merge either node.", - remember_relation_judgement_parameter_description: - "For action=relate, classify the nodes as same_entity, related, refines, conflict, or distinct. Use same_entity only for identity, not similarity; use distinct when an apparent match is actually a different entity or scope.", - node_name_parameter_description: - "Stable semantic cluster name. Related memories may share a node even when they describe different properties.", - recall_triggers_parameter_description: - "Optional short aliases or likely query phrases that should recall this record when the saved statement uses different wording. Use only distinctive wording a future user or Agent may actually search for; do not copy the whole statement, add broad topic tags, or encode new facts here. At most 16 entries.", - state_key_parameter_description: - "Stable key for exactly one replaceable property within one canonical scope. Reuse the key when the new value makes the old value no longer current; for example, running.charity_5k.personal_best changes from 27:12 to 25:50. Use different keys when both values can remain true; for example, a personal best and a target time are distinct properties. Good: pi-lsp.installation.path. Bad: pi-lsp-env used for installation path, tool inventory, and patch policy. A newer state with the same key and canonical scope automatically supersedes the previous active state, so a broad or wrongly reused key can incorrectly retire an unrelated state.", - external_source_parameter_description: - "External provenance reference beginning with web: or file:.", - evidence_parameter_description: - "Smallest exact excerpt that supports the saved statement. Copy it from the user, assistant, or tool source identified by sourceActor; do not summarize or include surrounding routine prose. Pi binds a matching excerpt to its source message. Use externalSource for web/file evidence.", - source_actor_parameter_description: - "Author of the supporting evidence, not the desired authority of the memory. Omit only for Assistant-created content (the safe default). Use user, tool, or system only with an exact evidence excerpt from that source in the current Pi session, or with an explicit externalSource for tool-derived web/file evidence. Never mark an Assistant inference or summary as sourceActor=user.", - active_graph_id_parameter_description: - "Optional activeGraphId returned by nmg_search. For action=feedback, omit it to target the latest retrieval owned by the current Pi session; pass it to label a specific older retrieval. For nmg_get, pass it when available to attribute use.", - mcp_active_graph_id_parameter_description: - "Optional activeGraphId returned by nmg_search. Pass it to nmg_get when available so exact disclosure is attributed to this MCP session, or to claim_outcome when the selected claim came from that retrieval.", - feedback_note_parameter_description: - "Optional concise reason for an explicit retrieval/task label. Do not include hidden reasoning, secrets, or infer success merely because no correction was made.", - feedback_label_parameter_description: - "Explicit observed label for action=feedback. Omit when unknown; false means the condition was actually reviewed and absent, not merely that nobody said it.", - semantic_task_id_parameter_description: - "Optional stable identifier shared by retries of the same semantic task. It is used for calibration deduplication, not session authorization or memory scope.", - search_query_parameter_description: - "Natural-language retrieval clause; may include key:value filters, -term exclusions, and quoted phrases.", - search_queries_parameter_description: - "Optional Agent-generated retrieval clauses, such as query decomposition, alternate wording, or a hypothetical-answer (HyDE) clause. NMG does not generate these clauses; it searches them, removes duplicate records, and appends unique results after the primary query's ranking.", - search_progression_required: - "NMG paused another search because two searches were already completed without loading selected evidence. Use nmg_get on relevant returned memory IDs, or use a current-source tool when the missing fact concerns live code, files, or the web. Do not keep paraphrasing the same historical search.", - search_recommendation: - "Automatic recall may be incomplete. If prior experience could change the answer, make one deliberate nmg_search call and then inspect only useful IDs with nmg_get.", - completion_nudge: - "A code commit or task completion was just detected. NMG long-term memory is available: you may recall relevant decisions (nmg_search) or save this turn's key conclusions (nmg_remember) if useful. This is only a reminder; it does not affect the current work.", - shadow_feedback_nudge: - "The previous NMG retrieval activeGraphId={active_graph_id} has completed and remains unlabelled. If the preceding answer and tool evidence let you review it, call nmg_remember action=feedback with semanticTaskId={semantic_task_id} and explicit values for evidenceSufficient, expansionUseful, excessiveNoise, and noMemoryNeeded. Omit the feedback call when any required label would be a guess. Silence, task completion, and lack of user correction are not labels.", - shadow_claim_outcome_nudge: - "The previous NMG retrieval activeGraphId={active_graph_id} disclosed saved memory IDs {memory_ids} through exact get and completed under semanticTaskId={semantic_task_id}. Inspect only the current user message or a successful tool result from this new turn. If it independently and unambiguously supports or contradicts one of that graph's disclosed saved claims, call nmg_remember action=claim_outcome with the exact memoryId, activeGraphId, semanticTaskId, claimOutcomeSource=user or claimOutcomeSource=tool, and an exact evidence excerpt. Otherwise omit the call. Answer overlap, retrieval, task success, silence, lack of correction, and failed tool output are not claim evidence.", - search_disclosure: - 'NMG MEMORY CANDIDATES\nformat: one candidate per line; fields separated by "; ".\nfields: memory=id; node=semantic cluster; type=memory type; time=event date; expires=expiry date; chains=logical chain names; preview=statement excerpt\nOptional [external] and [open] markers describe provenance and unresolved state.\nRecords are ranked by retrieval score; order is not a guarantee of relevance.\n', - search_disclosure_metadata: "NMG search metadata: count={count}\n{next_step}\n{forget_hint}\n", - get_disclosure: - "NMG selected evidence:\n- statement: the saved record content\n- SOURCE=: original evidence content when it differs from the statement\n- id/node/type/truth/scope identify the record\n", - get_disclosure_metadata: "NMG evidence metadata: count={count}\n{next_step}\n{forget_hint}\n", - deferred_hint: - "More ranked records are folded. If these are insufficient, call nmg_get once with the deferred memory IDs; do not repeat nmg_search.", - get_hint: - "Use nmg_get with selected memory IDs and the activeGraphId to load exact records and source evidence.", - forget_hint: "A line beginning with [forget] is a revocation boundary; treat it as revoked.", - in_context_title: "NMG ALREADY IN CURRENT CONTEXT", - memory_policy: - 'NMG supplies durable memory; it does not decide answer truth or evidence completeness. nmg_automatic_recall and nmg_search return candidate headers. nmg_get loads selected exact records and source evidence. nmg_remember saves durable information. Automatic recall uses a bounded current-task context, but can still miss a relevant domain. For the latest user request, decide which candidates matter, whether one or several records are needed, and whether more recall or current verification is required. If the current task clearly depends on prior project state and automatic headers are absent or insufficient, use nmg_search before guessing; this is not a requirement to search on every turn. NMG is historical memory, not a live code/file/web source: after one deliberate search and selected get fail to supply the missing fact, stop reformulating the same request and either use an appropriate current-source tool or state that the current fact was not verified. No useful memory is a valid result. Do not treat candidate count as completeness or a memory as current truth. Semantic or topical relevance does not prove the specific attribute, event, reason, or causal relation being asked about. If the recalled headers under-determine the answer, request additional evidence (append/re-fetch) before guessing; ignore headers that are clearly irrelevant noise. When an exact current-session user message or successful tool result independently supports or contradicts a disclosed saved claim, record nmg_remember action=claim_outcome with that exact excerpt; otherwise omit it. Retrieval, answer reuse, task completion, silence, or lack of correction are not claim evidence. Without waiting for an explicit "remember" request, save newly confirmed durable user facts, preferences, constraints, decisions, and reusable experience when they are likely to matter in a later session. Reuse an existing state key and scope for updates; search first when duplication is plausible. Ask instead of saving when attribution, scope, or durability is materially ambiguous. Save only attributable, durable information; do not save secrets, transient content, unconfirmed assistant proposals, unsupported guesses, or information already stored.', - claim_outcome_parameter_description: - "Explicit result for selected saved claims: supported or contradicted. This is evidence about the claim itself, not retrieval quality. Never infer support merely because a memory was retrieved, used in an answer, or not corrected.", - claim_outcome_source_parameter_description: - "Attribution class for the outcome: user, tool, or task. User/tool outcomes require an exact evidence excerpt from the current Pi session; task outcomes require claimSourceLineage. Benchmark outcomes are reserved for eval/CLI.", - claim_source_lineage_parameter_description: - "Stable identity of an independently attributable completed task. For user/tool outcomes Pi derives lineage from the exact evidence message instead of trusting a model-provided ID. Reusing lineage prevents one source from becoming multiple independent votes.", - claim_indexes_parameter_description: - "Optional zero-based indexes of atomic claims within the memory. Omit to apply the explicit outcome to every claim in that memory.", -} as const; From 92684a96af3c4a57f08399643d8ef788b622faf5 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:24:46 +0800 Subject: [PATCH 3/9] build: verify subpackage builds and lockfile sync; document artifact policy Generated outputs are excluded from version control, so buildability from a clean clone becomes the integrity guarantee. Add the verification that makes that guarantee enforceable, plus the documentation of why artifacts are untracked (and the anchors rename that motivated the governance pass). Verification: - tools/verify-packages.ts: install + build every subpackage with its own package.json/lockfile from a frozen lockfile (currently dsh/dsh-nmg via pnpm); wired into verify:static and CI's static job (corepack enables pnpm) - tools/check-lock.ts: fail when root package-lock.json drifted from package.json; wired into verify:static - ci.yml: static job enables corepack pnpm before the shared contract Docs: - docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git{.md,.zh-CN.md}: why regenerable outputs stay untracked (lockfiles are the deliberate exception), registered in decisions READMEs - docs/decisions/rejected/2026-09-02-keep-bookmarks-named-anchors{.md,.zh-CN.md}: why the bookmark feature was renamed anchors -> tesserae (surface/task/support anchor collision) - skills/repo-development/SKILL.md: new 'Builds and generated artifacts' section (reproduction order, verify:packages/check:lock); AGENTS.md points at it - .gitignore: clarify that dsh/dsh-nmg's real lockfile is pnpm-lock.yaml; the ignored package-lock.json only appears on an accidental npm install Verification: verify:static green (check, check:lock, lint, format:check, docs:check 107 files 0 errors, complexity:gate, verify:packages); docs tests 12/12. --- .github/workflows/ci.yml | 6 ++ .gitignore | 2 + AGENTS.md | 4 + docs/decisions/README.md | 5 ++ docs/decisions/README.zh-CN.md | 5 ++ ...2026-09-02-keep-bookmarks-named-anchors.md | 77 ++++++++++++++++ ...9-02-keep-bookmarks-named-anchors.zh-CN.md | 39 ++++++++ ...2026-09-02-track-build-artifacts-in-git.md | 64 +++++++++++++ ...9-02-track-build-artifacts-in-git.zh-CN.md | 34 +++++++ package.json | 4 +- skills/repo-development/SKILL.md | 27 ++++++ tools/check-lock.ts | 55 ++++++++++++ tools/verify-packages.ts | 89 +++++++++++++++++++ 13 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/rejected/2026-09-02-keep-bookmarks-named-anchors.md create mode 100644 docs/decisions/rejected/2026-09-02-keep-bookmarks-named-anchors.zh-CN.md create mode 100644 docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git.md create mode 100644 docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git.zh-CN.md create mode 100644 tools/check-lock.ts create mode 100644 tools/verify-packages.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25c9266..012811f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,12 @@ jobs: node-version: "24" cache: npm - run: npm ci + # Subpackage builds (verify:packages) use pnpm via the dsh/dsh-nmg + # pnpm-lock.yaml; corepack is bundled with the Node distribution. + - name: Enable corepack pnpm + run: | + corepack enable + pnpm --version - name: Shared static verification contract run: npm run verify:static - name: Dependency audit diff --git a/.gitignore b/.gitignore index 75318c7..3c32758 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,8 @@ evals/snapshots/ # Generated build artifacts (regenerated by npm run build / dsh-nmg tsdown) /src/prompts/nmg-prompts.generated.ts dsh/dsh-nmg/lib/ +# dsh/dsh-nmg's real lockfile is pnpm-lock.yaml (tracked); package-lock.json +# only appears if someone runs `npm install` inside the subpackage by mistake. dsh/dsh-nmg/package-lock.json # Large/generated binary artifacts (should never be tracked) diff --git a/AGENTS.md b/AGENTS.md index 6d4e1b2..05e36e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,3 +16,7 @@ Before modifying the repository, follow `skills/repo-development/SKILL.md`: For documentation changes, also follow `skills/doc-maintenance/SKILL.md`. For using NMG memory or its coordination board, follow `skills/nmg-memory/SKILL.md`. + +Build outputs (`dist/`, `dsh/dsh-nmg/lib/`, generated prompts) are not tracked; +see the "Builds and generated artifacts" section of `skills/repo-development/SKILL.md` +for reproduction order and `verify:packages` / `check:lock`. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index aa3f0c0..48b2774 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -44,3 +44,8 @@ to one another. Missing translations are reported as warnings, not hard errors. ## Implemented decisions - [External Repository Control Plane](implemented/2026-08-29-repository-control-plane.md) + +## Rejected decisions + +- [Track build artifacts in version control](rejected/2026-09-02-track-build-artifacts-in-git.md) — regenerable outputs stay untracked; buildability is verified, not committed +- [Keep the bookmark feature named "anchors"](rejected/2026-09-02-keep-bookmarks-named-anchors.md) — renamed to tesserae to end collision with surface/task/support anchors diff --git a/docs/decisions/README.zh-CN.md b/docs/decisions/README.zh-CN.md index a249b1f..f0bcbdb 100644 --- a/docs/decisions/README.zh-CN.md +++ b/docs/decisions/README.zh-CN.md @@ -30,3 +30,8 @@ ## 已实现决策 - [外部 Repository Control Plane](implemented/2026-08-29-repository-control-plane.zh-CN.md) + +## 被拒决策 + +- [将构建产物纳入版本控制](rejected/2026-09-02-track-build-artifacts-in-git.zh-CN.md) — 可再生输出保持不入库;可构建性靠验证而非提交 +- [书签功能继续命名为 "anchors"](rejected/2026-09-02-keep-bookmarks-named-anchors.zh-CN.md) — 改名为 tessera,终结与 surface/task/support anchors 的撞名 diff --git a/docs/decisions/rejected/2026-09-02-keep-bookmarks-named-anchors.md b/docs/decisions/rejected/2026-09-02-keep-bookmarks-named-anchors.md new file mode 100644 index 0000000..ecdbf53 --- /dev/null +++ b/docs/decisions/rejected/2026-09-02-keep-bookmarks-named-anchors.md @@ -0,0 +1,77 @@ +# Keep the bookmark feature named "anchors" + +[中文](2026-09-02-keep-bookmarks-named-anchors.zh-CN.md) + +**Status:** rejected +**Date:** 2026-09-02 + +## Problem + +The memory-bookmark feature (file locations a memory points into, content-anchored +by snippet) shipped as "anchors" (`anchors` table, `anchor_ref` markers, +`--anchor` CLI flag). While reviewing the name for long-term clarity, three +unrelated "anchor" concepts were found already living in the same codebase and +ecosystem: + +- **Surface anchors** (`surfaceAnchorCandidates` / `surfaceAnchors` in + `src/core/store/`): retrieval-side explicit quoted phrases/paths/IDs indexed + for exact-match search — an unrelated, pre-existing product concept in the + _same search path_ as the bookmark hits. +- **Pi task anchors** (`state.anchors` in the Pi extension): recent substantive + user-task context carried across terse turns. +- **Support anchors** (`reasoning-workspace`, `qpp`, `hierarchical-activation`): + "a stable evidence reference" in Lab reasoning, the top-1 query anchor in QPP, + LTG node vectors as anchors. + +One word, four meanings; two of them inside the same file +(`src/core/store/retrieval.ts` hosts both `surfaceAnchorCandidates` and +`searchAnchors`). + +## Proposal + +Keep the shipped name "anchors" for bookmarks, relying on context and the +`(bookmark)` parenthetical to disambiguate. + +## Alternatives considered + +- **Qualified name, e.g. `memory-anchor` / `file-anchor`.** Rejected: it is + longer on every CLI flag and still overloads the shared word; grep and search + results would need the qualifier to be meaningful. +- **Keep "anchor" only in code, rename user-facing surfaces.** Rejected: the + confusion is worst at the rendering boundary (`anchor=` lines), so partial + renaming leaves the collision where readers actually see it. +- **Rename the other concepts instead.** Rejected: `surfaceAnchor` is an older, + widely-referenced retrieval term (design docs, benchmark notes), and Pi's + task-anchor lives in another repository we do not own; renaming bookmarks was + the single change fully inside our control. + +## Why rejected + +- **Same search path, two meanings.** Bookmark hits and surface-anchor hits both + flow through the retrieval context; a reader or agent seeing `anchor=` render + lines cannot tell which concept produced them without reading the code. +- **Retrieval quality discussion needs the distinction.** The retrieval + benchmark and design notes distinguish surface anchors (explicit-token + retrieval) from ordinary word overlap; overloading "anchor" makes that + discussion ambiguous. +- **Rename cost was lowest at this moment.** The feature was merged days earlier, + had no external consumers, and all real-store rows were test data — a rename + was a mechanical, low-risk operation (see the rename PR). Naming debt only + compounds with age. +- The replacement name chosen was **tessera** (plural _tesserae_), from the + Latin _tessera hospitalis_ — a token broken in two so matching the halves + proves identity — matching the snippet-relocation model (the bookmark's + snippet half must match the file's content half). The word had zero prior + usage in the codebase, so it cannot collide. + +## Consequences + +- The feature is now `tesserae` end-to-end: table, FTS, markers + (`tessera_ref`), CLI (`--tessera`), search rendering (`tessera=`), types + (`TesseraRecord/Input/Hit`), and the design doc + (`docs/design/memory-tesserae-design.md`). +- A forward migration renames a pre-rename `anchors` table in place and rewrites + `anchor_ref` markers to `tessera_ref`, so existing stores upgrade without data + loss. +- "Anchor" remains in the codebase only where it means one of the other three + concepts (surface / task / support), each now unambiguous. diff --git a/docs/decisions/rejected/2026-09-02-keep-bookmarks-named-anchors.zh-CN.md b/docs/decisions/rejected/2026-09-02-keep-bookmarks-named-anchors.zh-CN.md new file mode 100644 index 0000000..2eaac66 --- /dev/null +++ b/docs/decisions/rejected/2026-09-02-keep-bookmarks-named-anchors.zh-CN.md @@ -0,0 +1,39 @@ +# 书签功能继续命名为 "anchors" + +[English](2026-09-02-keep-bookmarks-named-anchors.md) + +**Status:** rejected +**Date:** 2026-09-02 + +## Problem + +记忆书签功能(记忆指向的文件位置,以内容 snippet 为锚)以 "anchors" 之名发布(`anchors` 表、`anchor_ref` markers、`--anchor` CLI 参数)。在审视长期清晰度时,发现代码库与生态中已存在三个无关的 "anchor" 概念: + +- **Surface anchors**(`src/core/store/` 的 `surfaceAnchorCandidates` / `surfaceAnchors`):检索侧的显式引号短语/路径/ID,为精确匹配搜索建索引——一个无关的、先于本功能存在的产品概念,且位于与书签命中**同一条搜索路径**。 +- **Pi task anchors**(Pi 扩展的 `state.anchors`):跨简洁轮次携带的近期实质性用户任务上下文。 +- **Support anchors**(`reasoning-workspace`、`qpp`、`hierarchical-activation`):Lab 推理中的"稳定证据引用"、QPP 的 top-1 查询锚、LTG 节点向量锚。 + +一个词,四种含义;其中两种在同一个文件里(`src/core/store/retrieval.ts` 同时承载 `surfaceAnchorCandidates` 与 `searchAnchors`)。 + +## 提案 + +保留已发布的 "anchors" 命名,依靠上下文与 `(bookmark)` 括注消歧。 + +## 考虑过的替代方案 + +- **限定名,如 `memory-anchor` / `file-anchor`。** 拒绝:每个 CLI 参数更长,且仍重载共享词;grep 与搜索结果必须带限定词才有意义。 +- **代码里保留 "anchor",只改用户可见表面。** 拒绝:混淆最严重处正是渲染边界(`anchor=` 行),部分改名把撞名留在读者实际看到的位置。 +- **改为改其他概念。** 拒绝:`surfaceAnchor` 是更早、被广泛引用的检索术语(设计文档、benchmark 笔记),Pi 的 task-anchor 位于我们无权修改的另一个仓库;改书签名是我们完全掌控内的唯一改动。 + +## 为什么拒绝 + +- **同一条搜索路径,两种含义。** 书签命中与 surface-anchor 命中都流经检索上下文;读者或 agent 看到 `anchor=` 渲染行,不读代码无法分辨是哪一种概念产生的。 +- **检索质量讨论需要区分。** 检索 benchmark 与设计笔记区分 surface anchors(显式 token 检索)与普通词重叠;重载 "anchor" 使该讨论含混。 +- **改名成本此时最低。** 功能几天前才合入、无外部消费者、真实库存量行全是测试数据——改名是机械、低风险操作(见改名 PR)。命名债务只随年龄增长。 +- 替代名选定为 **tessera**(复数 _tesserae_),源自拉丁语 _tessera hospitalis_——一分为二、相合证身份的凭证牌——契合 snippet 重定位模型(书签的 snippet 半必须与文件内容半相合)。该词在代码库零先例,不可能撞名。 + +## Consequences + +- 功能现以 `tesserae` 全链路命名:表、FTS、markers(`tessera_ref`)、CLI(`--tessera`)、检索渲染(`tessera=`)、类型(`TesseraRecord/Input/Hit`)与设计文档(`docs/design/memory-tesserae-design.md`)。 +- 前向迁移将改名前的 `anchors` 表原位 RENAME 并把 `anchor_ref` markers 重写为 `tessera_ref`,存量库无数据丢失升级。 +- "Anchor" 仅在指代其余三种概念(surface / task / support)时留在代码库中,各自不再含混。 diff --git a/docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git.md b/docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git.md new file mode 100644 index 0000000..11ead89 --- /dev/null +++ b/docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git.md @@ -0,0 +1,64 @@ +# Track build artifacts in version control + +[中文](2026-09-02-track-build-artifacts-in-git.zh-CN.md) + +**Status:** rejected +**Date:** 2026-09-02 + +## Problem + +The repository contains several regenerable outputs: `dsh/dsh-nmg/lib/` (tsdown +build output of the DSH host plugin), `src/prompts/nmg-prompts.generated.ts` +(emitted from `nmg-prompts.yaml` by `scripts/generate-prompts.ts`), and +`.nmg-search-scope` (a runtime hot-zone manifest). For a long time the working +tree carried modified-but-uncommitted copies of `lib/` and `package-lock.json`, +because PRs committed only `src/` while the tracked artifacts drifted behind. + +That produced the worst of both worlds: artifacts were tracked (so a fresh clone +carried stale copies), yet never updated in step with their sources (so the +tracked copy was wrong and the tree was permanently dirty). The drift surfaced +as real failures — a stale `lib/index.js` that still spoke the old `anchors` +RPC surface while the daemon and CLI had already moved to `tesserae`. + +## Proposal + +Commit build artifacts (`dsh/dsh-nmg/lib/`, generated prompts) in the same +commit as their sources, keeping the repository always-buildable from a clean +clone and the tree permanently clean. + +## Alternatives considered + +- **Ignore the artifacts but keep them tracked as they are today.** Rejected: + this was the status quo that produced permanent tree dirt and stale tracked + copies — tracked yet never in step with their sources. +- **Generate into a separate location outside the repository.** Rejected for + now: `dsh/dsh-nmg` is consumed by a `link:` install that expects `lib/` next + to `package.json`; moving the output would break the DSH plugin contract. + +## Why rejected + +- **Generated outputs are not source.** They carry no design intent and cannot + be reviewed meaningfully; a diff over `lib/index.js` is noise that obscures + the real `src/` change. +- **They drift by construction.** Every artifact regenerates with a different + timestamp/content hash per machine and toolchain version, so "commit them with + every source change" is an unenforceable discipline that will silently lapse + again (as it did before). +- **The right guarantee is buildability, not committed artifacts.** A clean + clone must be able to regenerate everything — that is a _verification_ + property, owned by CI and the Repository Control Plane (`verify:packages`, + `verify:static`), not a _content_ property of the tree. +- Lockfiles are the deliberate exception: `package-lock.json` / `pnpm-lock.yaml` + pin the dependency graph and are configuration, not regenerable build output. + They stay tracked, and drift between `package.json` and the lockfile is caught + by `npm ci` and `check:lock`. + +## Consequences + +- `dsh/dsh-nmg/lib/`, `src/prompts/nmg-prompts.generated.ts`, and + `.nmg-search-scope` are untracked and ignored. +- Fresh consumers of `dsh/dsh-nmg` must run `pnpm install --frozen-lockfile && +pnpm run build` before the package is usable (see + `skills/repo-development/SKILL.md`). +- CI verifies subpackage buildability from a clean checkout via + `verify:packages`, so artifact exclusion cannot silently rot the build. diff --git a/docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git.zh-CN.md b/docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git.zh-CN.md new file mode 100644 index 0000000..d953dd8 --- /dev/null +++ b/docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git.zh-CN.md @@ -0,0 +1,34 @@ +# 将构建产物纳入版本控制 + +[English](2026-09-02-track-build-artifacts-in-git.md) + +**Status:** rejected +**Date:** 2026-09-02 + +## Problem + +仓库中存在若干可再生的输出:`dsh/dsh-nmg/lib/`(DSH 宿主插件的 tsdown 构建产物)、`src/prompts/nmg-prompts.generated.ts`(由 `scripts/generate-prompts.ts` 从 `nmg-prompts.yaml` 生成)、`.nmg-search-scope`(运行时热区清单)。很长一段时间里,工作树一直带着已修改但未提交的 `lib/` 与 `package-lock.json` 副本——因为 PR 只提交 `src/`,而被跟踪的产物持续落后于源码。 + +这造成了最差的两难:产物被跟踪(全新 clone 会拿到过时副本),却从不与其源同步更新(被跟踪副本是错的,工作树永远脏)。漂移以真实故障形式暴露——过时的 `lib/index.js` 仍在讲旧 `anchors` RPC 表面,而 daemon 与 CLI 早已迁到 `tesserae`。 + +## 提案 + +将构建产物(`dsh/dsh-nmg/lib/`、生成的 prompts)与其源放在同一提交中入库,使仓库从干净 clone 即可构建、工作树始终干净。 + +## 考虑过的替代方案 + +- **忽略产物但仍像现在这样跟踪它们。** 拒绝:这正是造成工作树永久脏、被跟踪副本过时的现状——被跟踪却从不与其源同步。 +- **生成到仓库外的独立位置。** 暂拒:`dsh/dsh-nmg` 由 `link:` 安装消费,期望 `lib/` 与 `package.json` 相邻;移动输出会破坏 DSH 插件契约。 + +## 为什么拒绝 + +- **生成产物不是源码。** 它们不携带设计意图,无法被有意义的评审;对 `lib/index.js` 的 diff 是淹没真实 `src/` 变更的噪音。 +- **它们按构造就会漂移。** 每个产物随机器与工具链版本以不同的时间戳/内容哈希再生,因此"每次源码变更都随提交"是无法执行的纪律,会像过去一样悄然失效。 +- **正确的保证是可构建性,而非提交产物。** 干净 clone 必须能再生一切——这是**验证**属性,由 CI 与 Repository Control Plane(`verify:packages`、`verify:static`)拥有,而非工作树的内容属性。 +- Lockfile 是刻意的例外:`package-lock.json` / `pnpm-lock.yaml` 固定依赖图,是配置而非可再生的构建输出。它们保持跟踪;`package.json` 与 lockfile 的漂移由 `npm ci` 与 `check:lock` 捕获。 + +## Consequences + +- `dsh/dsh-nmg/lib/`、`src/prompts/nmg-prompts.generated.ts`、`.nmg-search-scope` 不再跟踪并被忽略。 +- `dsh/dsh-nmg` 的新消费者必须先运行 `pnpm install --frozen-lockfile && pnpm run build` 才能使用该包(见 `skills/repo-development/SKILL.md`)。 +- CI 通过 `verify:packages` 在干净 checkout 上验证子包可构建性,因此产物排除不会悄然腐蚀构建。 diff --git a/package.json b/package.json index 55f558f..b1df2c1 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,9 @@ "agent:context:check": "node --experimental-strip-types tools/repo-context.ts --check", "agent:verify": "node --experimental-strip-types tools/agent-verify.ts", "complexity:gate": "node --experimental-strip-types tools/complexity-gate.ts", - "verify:static": "npm run build && npm run package:check && npm run check && npm run lint && npm run format:check && npm run docs:check && npm run agent:context:check && npm run complexity:gate", + "verify:packages": "node --experimental-strip-types tools/verify-packages.ts", + "check:lock": "node --experimental-strip-types tools/check-lock.ts", + "verify:static": "npm run build && npm run package:check && npm run check && npm run check:lock && npm run lint && npm run format:check && npm run docs:check && npm run agent:context:check && npm run complexity:gate && npm run verify:packages", "verify:product-ci": "npm run build && npm run test:coverage", "verify:research": "npm run test:research", "verify:node-compat": "npm run build && npm run check && npm run package:check", diff --git a/skills/repo-development/SKILL.md b/skills/repo-development/SKILL.md index e43e78b..2caf5ff 100644 --- a/skills/repo-development/SKILL.md +++ b/skills/repo-development/SKILL.md @@ -91,4 +91,31 @@ or remove it when its exit criteria are met. abandoned. The board records that work is active, not a step-by-step history; Git and verification evidence remain the source of actual implementation state. +## Builds and generated artifacts + +Regenerable outputs are **not** tracked (see the rejected decision +[Track build artifacts in version control](../../docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git.md)): + +- `dist/` (root tsc build), `dsh/dsh-nmg/lib/` (tsdown), and + `src/prompts/nmg-prompts.generated.ts` (from `nmg-prompts.yaml`) are + gitignored; the tree stays clean only if you never `git add` them. +- A change to `src/` that feeds a generated output is verified by + regeneration, not by committing the output. + +Reproduce locally, in this order: + +1. Root package: `npm ci` (or `npm install` when adding a dependency), then + `npm run build` — regenerates `src/prompts/nmg-prompts.generated.ts` and + `dist/`. +2. Subpackages with their own lockfile (currently `dsh/dsh-nmg`, pnpm): + `cd dsh/dsh-nmg && pnpm install --frozen-lockfile && pnpm run build` — + regenerates `lib/`. `npm run verify:packages` runs every subpackage from a + frozen lockfile automatically. +3. `npm run check:lock` fails when the root `package-lock.json` drifted from + `package.json`; fix with `npm install --package-lock-only`. + +When a change touches a subpackage's `src/`, `package.json`, or its lockfile, +`npm run agent:verify` covers it through `verify:static` → +`verify:packages`/`check:lock`. + Never invoke live LLM, embedding, or full benchmark workloads unless the task explicitly calls for them. diff --git a/tools/check-lock.ts b/tools/check-lock.ts new file mode 100644 index 0000000..739e851 --- /dev/null +++ b/tools/check-lock.ts @@ -0,0 +1,55 @@ +/** + * Verify the root package-lock.json is in sync with package.json. + * + * Rationale: the root lockfile is tracked (a configuration artifact, not a + * regenerable build output). A dependency edit that forgets to sync the lock + * passes local builds but fails CI's `npm ci`, so catching it in the shared + * verification chain saves a CI round-trip. The check is a local diff of the + * root manifest's dependency specifiers against the lockfile's root entry — + * no network, no install. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +}; +const lock = JSON.parse(readFileSync(resolve(root, "package-lock.json"), "utf8")) as { + packages?: Record; devDependencies?: Record; optionalDependencies?: Record; peerDependencies?: Record }>; +}; + +const lockRoot = lock.packages?.[""]; +if (!lockRoot) { + process.stderr.write("check:lock — package-lock.json has no root package entry\n"); + process.exitCode = 1; +} else { + const expected = { + ...(manifest.dependencies ?? {}), + ...(manifest.devDependencies ?? {}), + ...(manifest.optionalDependencies ?? {}), + ...(manifest.peerDependencies ?? {}), + }; + const locked = { + ...(lockRoot.dependencies ?? {}), + ...(lockRoot.devDependencies ?? {}), + ...(lockRoot.optionalDependencies ?? {}), + ...(lockRoot.peerDependencies ?? {}), + }; + const drift: string[] = []; + for (const [name, spec] of Object.entries(expected)) { + if (locked[name] !== spec) drift.push(`${name}: package.json "${spec}" != lock "${locked[name] ?? "(missing)"}"`); + } + for (const name of Object.keys(locked)) { + if (!(name in expected)) drift.push(`${name}: present in lock but not in package.json`); + } + if (drift.length > 0) { + process.stderr.write("check:lock — package-lock.json is stale; run `npm install --package-lock-only`:\n" + drift.map((line) => ` - ${line}`).join("\n") + "\n"); + process.exitCode = 1; + } else { + process.stdout.write(`check:lock ok: ${Object.keys(expected).length} root dependency specifiers match package-lock.json\n`); + } +} diff --git a/tools/verify-packages.ts b/tools/verify-packages.ts new file mode 100644 index 0000000..905f8eb --- /dev/null +++ b/tools/verify-packages.ts @@ -0,0 +1,89 @@ +/** + * Verify every tracked subpackage builds reproducibly from a clean install. + * + * Rationale: build outputs (dist/, lib/, generated sources) are excluded from + * version control, so the only integrity check left is "a clean clone can + * regenerate every artifact". Each subpackage carries its own package.json and + * lockfile (e.g. dsh/dsh-nmg uses pnpm); this tool installs with the frozen + * lockfile (which also fails when package.json drifted from the lock) and runs + * its build script. + * + * The package list is explicit, not a glob over node_modules, so adding a + * subpackage is a one-line, reviewable change. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); + +interface Subpackage { + /** Directory relative to the repository root, containing package.json. */ + dir: string; + /** Package manager used by this subpackage (its lockfile kind). */ + manager: "pnpm" | "npm"; +} + +// Explicit list of subpackages with their own lockfile + build lifecycle. +const SUBPACKAGES: Subpackage[] = [{ dir: "dsh/dsh-nmg", manager: "pnpm" }]; + +function run(command: string, args: string[], cwd: string): void { + if (process.platform === "win32") { + // .cmd shims cannot be spawned directly; route through the shell like the + // rest of the repository tooling (see tools/verify-package.ts). + const shellArgs = ["/d", "/s", "/c", `${command} ${args.map(quote).join(" ")}`]; + execFileSync(process.env.ComSpec ?? "cmd.exe", shellArgs, { + cwd, + stdio: "inherit", + encoding: "utf8", + }); + return; + } + execFileSync(command, args, { cwd, stdio: "inherit", encoding: "utf8" }); +} + +function quote(value: string): string { + return /[\s"]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value; +} + +let failures = 0; +for (const sub of SUBPACKAGES) { + const dir = resolve(root, sub.dir); + const manifestPath = resolve(dir, "package.json"); + if (!existsSync(manifestPath)) { + process.stderr.write(`verify:packages — missing ${sub.dir}/package.json\n`); + failures += 1; + continue; + } + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + scripts?: Record; + }; + process.stdout.write(`\nverify:packages — ${sub.dir} (${sub.manager})\n`); + try { + const installArgs = + sub.manager === "pnpm" + ? ["install", "--frozen-lockfile"] + : ["ci"]; + run(sub.manager, installArgs, dir); + if (manifest.scripts?.build) { + run(sub.manager, ["run", "build"], dir); + } else { + process.stdout.write(` (no build script; install-only check)\n`); + } + process.stdout.write(` ok\n`); + } catch (error) { + failures += 1; + process.stderr.write( + `verify:packages — ${sub.dir} failed: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + } +} + +if (failures > 0) { + process.stderr.write(`\nverify:packages — ${failures} subpackage(s) failed\n`); + process.exitCode = 1; +} else { + process.stdout.write(`\nverify:packages ok: ${SUBPACKAGES.length} subpackage(s) build from clean install\n`); +} From a8dfe15c1ffe76cb1a7e02a58f28cf81cbc4feb2 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:30:17 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix(deps):=20bump=20fast-uri=203.1.5?= =?UTF-8?q?=E2=86=923.1.7=20and=20qs=20to=20clear=20npm=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's Dependency audit step failed on newly published advisories: - fast-uri <3.1.7 (high): host confusion / SSRF via IDN and IPv6 normalization (GHSA-5jgf-p345-68v8, GHSA-f65p-4m7j-42xc, ...) - qs <6.16 (moderate): array-limit bypass via bracket-key parsing npm audit fix upgraded both within their declared ranges (no overrides); audit now reports 0 vulnerabilities. check + integration tests still green. --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7e2aa9e..dbc7e49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2663,9 +2663,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -3948,9 +3948,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", From a1df2f6d26749327433b4eed94783cbcbea31f47 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:30:46 +0800 Subject: [PATCH 5/9] docs(pr): add artifact-governance checks to the PR template checklist Surface the rules added by the artifact-governance pass as explicit pre-merge checklist items so submitters verify them locally instead of relying on CI alone: - verify:static now includes check:lock and verify:packages - dependency/lockfile changes require check:lock + a clean npm audit - subpackage changes require verify:packages + a synced lockfile - regenerable outputs (dist/, dsh/dsh-nmg/lib/, generated prompts, .nmg-search-scope) must not be committed - CI confirmation: Static job includes Dependency audit; when an upstream advisory fails it, fix the lockfile rather than loosening the audit gate --- .github/pull_request_template.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 25b0a49..ede35e8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,10 +17,13 @@ ### 本地质量检查 -- [ ] `npm run verify:static` 通过(build / package:check / tsc / lint / format:check / docs:check / agent:context:check / complexity:gate) +- [ ] `npm run verify:static` 通过(build / package:check / check / check:lock / lint / format:check / docs:check / agent:context:check / complexity:gate / verify:packages) - [ ] `npm run test:product` 通过(或按改动路由跑 `npm run agent:verify -- <路径>`) +- [ ] 改了依赖或 lockfile 时:`npm run check:lock` 通过、根 `package-lock.json` 已同步、`npm audit --omit=dev --audit-level=high` 无漏洞 +- [ ] 改了子包(`dsh/dsh-nmg` 等)时:`npm run verify:packages` 通过(frozen-lockfile install + build),`pnpm-lock.yaml`/lockfile 随 package.json 同步 - [ ] 文档改动跑过 `npm run docs:check`;决策/设计改动遵循 doc-maintenance 规范 - [ ] 新代码方法圈复杂度不超阈值(CodeFactor / `npm run complexity:gate`) +- [ ] 未提交可再生产物:`dist/`、`dsh/dsh-nmg/lib/`、`src/prompts/nmg-prompts.generated.ts`、`.nmg-search-scope` 不入库(见 `docs/decisions/rejected/2026-09-02-track-build-artifacts-in-git.md`) ### RCP(Repository Control Plane) @@ -34,6 +37,7 @@ - [ ] CI Status Snapshot(`.nmg-ci/status.json`)结论为 `workflow.conclusion: "success"` 且 `failures: []` —— 或 `gh pr checks | Select-String "All checks passed"` 出现且为 pass - [ ] CodeFactor 通过 +- [ ] Static job 通过(含 `verify:static` 全部子检查 + Dependency audit;audit 因上游新 advisory 失败时,先 `npm audit fix` 再更新 `package-lock.json` 提交,不要改 audit 门槛) > CI Status Snapshot 是 GitHub 状态的只读观察(`authority: observation-only`), > 不是授权或合并决定;合并仍需显式操作。 From e25d2b9a68ed25d07b63fd5606cae607519bd379 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:33:32 +0800 Subject: [PATCH 6/9] fix(verify): generate prompts before verify:research MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research characterization imports src/prompts/load.ts → generated prompts. Now that nmg-prompts.generated.ts is untracked (regenerated on build), the research job failed with ERR_MODULE_NOT_FOUND on a clean checkout because it ran test:research without generating first. Align it with the other verification contracts: run prompts:generate first, like verify:static and verify:product-ci already do. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b1df2c1..e92b550 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "check:lock": "node --experimental-strip-types tools/check-lock.ts", "verify:static": "npm run build && npm run package:check && npm run check && npm run check:lock && npm run lint && npm run format:check && npm run docs:check && npm run agent:context:check && npm run complexity:gate && npm run verify:packages", "verify:product-ci": "npm run build && npm run test:coverage", - "verify:research": "npm run test:research", + "verify:research": "npm run prompts:generate && npm run test:research", "verify:node-compat": "npm run build && npm run check && npm run package:check", "verify:chaos": "npm run test:chaos", "bench:autodiff": "node --experimental-strip-types tools/autodiff-benchmark.ts", From a086db35e41631568c8aaeda4fda3db6ed2fcb8c Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:46:28 +0800 Subject: [PATCH 7/9] docs(skill): turn the PR template into the submit-time prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repo-development SKILL now tells an agent what to do when opening a PR and writing a commit, reusing the template as the checklist instead of duplicating it: - Commit: conventional style (type(scope): summary + what/why body), one change per commit, and no claim of a passing check unless it actually ran. - Pull request: read .github/pull_request_template.md and follow it as the PR prompt — fill What/Why/Changes from the change and self-check every box before marking ready. The template stays the single owner of the checklist; the SKILL points at it. --- skills/repo-development/SKILL.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/skills/repo-development/SKILL.md b/skills/repo-development/SKILL.md index 2caf5ff..1a550a6 100644 --- a/skills/repo-development/SKILL.md +++ b/skills/repo-development/SKILL.md @@ -87,7 +87,19 @@ or remove it when its exit criteria are met. or use `--require-clean` in an equivalent clean tree. CI automatically runs the named `verify:*` package contracts on push and pull request. 6. Commit one coherent change with only owned files. Leave unrelated user or Agent work untouched. -7. Resolve the in-flight goal after the task is completed or deliberately + Commit messages follow the repository's conventional style + (`type(scope): summary` + a body that says what changed and why, one change + per commit). A commit is a proposal, not a proof: the verification evidence + (targeted test + `agent:verify`) is what makes it hold, so do not claim a + check passed in the message unless it ran. +7. When opening a pull request, read `.github/pull_request_template.md` and + follow it as the PR prompt: fill the three description blocks (What / Why / + Changes) from the change, and self-check every box in the completion + checklist before marking the PR ready — the checklist is the same contract + CI enforces, and it catches locally what a CI round-trip would cost. Draft + PRs and CI status are owned by the forge; the template checklist is the + submitter's own pre-flight, not a substitute for `All checks passed`. +8. Resolve the in-flight goal after the task is completed or deliberately abandoned. The board records that work is active, not a step-by-step history; Git and verification evidence remain the source of actual implementation state. From 12c97644f052354eb912857dc79487ed66dc4802 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:49:42 +0800 Subject: [PATCH 8/9] docs(skill): surface nmg-rcp scenarios agent:verify does not cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent:verify auto-discovers the covering contract for ordinary changes, so the SKILL only ever pointed at it — the standalone nmg-rcp CLI (forge-status, plan/compile, receipt-*, reconcile --apply, forge-bind/create) was documented in ci-cd-and-quality.md §7.11 but never surfaced at the agent's decision point, so agents reached for ad-hoc commands instead. Add a section listing the exact scenarios that need manual nmg-rcp, each with the trigger and command, and state the governing rule: when a tool improves, update its owning documentation in the same change — an undocumented improvement is one agents will not use. --- skills/repo-development/SKILL.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/skills/repo-development/SKILL.md b/skills/repo-development/SKILL.md index 1a550a6..06d609e 100644 --- a/skills/repo-development/SKILL.md +++ b/skills/repo-development/SKILL.md @@ -103,6 +103,34 @@ or remove it when its exit criteria are met. abandoned. The board records that work is active, not a step-by-step history; Git and verification evidence remain the source of actual implementation state. +## Repository Control Plane beyond agent:verify + +`npm run agent:verify` auto-discovers the contract that uniquely covers the +current scope and runs the equivalent reconcile — that is the default for +ordinary changes (see [`ci-cd-and-quality.md` §7.11](../../docs/design/ci-cd-and-quality.md)). +Use the standalone `nmg-rcp` CLI (`node bin/nmg-rcp.mjs`, contract path first) +only in the scenarios `agent:verify` does not cover: + +- **Check CI state without opening the browser:** `nmg-rcp forge-status --pr ` + reads the forge's status-check rollup (`checks[]` with name/conclusion). Use + it before claiming "checks pass" or deciding a PR is mergeable. +- **Review what a reconcile would do before running it:** + `nmg-rcp plan ` (and `nmg-rcp compile ` when the contract + itself changed). +- **Inspect verification evidence:** `nmg-rcp receipt-list` / + `nmg-rcp receipt-verify ` / `nmg-rcp receipt-scan` — receipts live + under `.rcp/receipts/` and are append-only. +- **Retry after a failed reconcile, or run an explicit workspace-ready pass:** + `nmg-rcp reconcile --apply --workspace-ready [--recover-attempt]`. +- **Bind a PR or create a draft PR through the forge provider:** + `nmg-rcp forge-bind --pr ` / `nmg-rcp forge-create --base main --head `. + +`--apply` never runs by default; reconcile plans unless `--apply` is explicit. +When a Contract's status or verification drift from the design doc, update the +owning document (this SKILL, `ci-cd-and-quality.md`, the RCP decision) in the +same change — an improved tool that stays undocumented is a tool agents will +not reach for. + ## Builds and generated artifacts Regenerable outputs are **not** tracked (see the rejected decision From b12bf16d4f73ec9edfaa51235d6552be26e5d011 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:50:40 +0800 Subject: [PATCH 9/9] docs(pr): make the PR template an executable checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Position the template as the operation-layer checklist: each RCP item now carries the exact command that self-verifies it, so a submitter (human or agent) can work through the boxes without consulting the SKILL first — nmg board put for the in-flight goal, agent:verify / nmg-rcp reconcile for evidence, and nmg-rcp forge-status --pr for CI observation instead of manual polling. The SKILL's 'Repository Control Plane beyond agent:verify' section remains the detailed operator manual the template points at. --- .github/pull_request_template.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ede35e8..cc8eee2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -27,15 +27,27 @@ ### RCP(Repository Control Plane) -- [ ] 首个实质写入前已在 `repo-development` 黑板登记 in-flight goal(条目已 resolve) -- [ ] `npm run agent:verify -- <改动路径>` 跑过 reconcile,`.nmg/verification/latest.json` 覆盖改动路由 + + +- [ ] 首个实质写入前已在 `repo-development` 黑板登记 in-flight goal: + `nmg board put repo-development "goal=…; approach=…; scope=…" --agent --kind goal`(已 resolve) +- [ ] 改动路由的 reconcile 已通过并写入证据: + `npm run agent:verify -- <改动路径>`(或 `nmg-rcp reconcile --apply --workspace-ready`) + → `.nmg/verification/latest.json` 覆盖改动路由 +- [ ] CI 全绿是用 RCP 观察确认的,不是人肉轮询: + `nmg-rcp forge-status --pr ` 的 checks 全为 SUCCESS - [ ] 只提交本 PR 拥有的文件;未吞并行 Agent 的暂存/工作树改动 + (`git status --short` 核对无他人文件) ### CI 完成确认 - -- [ ] CI Status Snapshot(`.nmg-ci/status.json`)结论为 `workflow.conclusion: "success"` 且 `failures: []` - —— 或 `gh pr checks | Select-String "All checks passed"` 出现且为 pass + + +- [ ] `nmg-rcp forge-status --pr ` 的 `All checks passed` 为 SUCCESS + (或 CI Status Snapshot `.nmg-ci/status.json`:`conclusion: "success"` 且 `failures: []`) - [ ] CodeFactor 通过 - [ ] Static job 通过(含 `verify:static` 全部子检查 + Dependency audit;audit 因上游新 advisory 失败时,先 `npm audit fix` 再更新 `package-lock.json` 提交,不要改 audit 门槛)