From 89a4b3d5e24ff277814633350ab3163bcb025bfc Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:41:46 +0800 Subject: [PATCH 1/4] docs(design): memory anchors as independent searchable source First-principles redesign of how an Agent reaches file content through NMG. Drops the full-text file index (file-content-source) in favor of sparse, Agent-authored anchors: independent searchable rows (path, snippet, label), content-anchored (relocation not line numbers), linked to memory via the open-string markers channel (anchor_ref). Writing is active; recall is active + passive. Surveyed haido/gptme/agentic-bookmarks/ai-memory for the anchor + objective-staleness pattern. File-content-source-design is marked superseded; its 'files are not memory' and separated-presentation conclusions carry forward. --- docs/design/file-content-source-design.md | 18 ++- docs/design/memory-anchors-design.md | 182 ++++++++++++++++++++++ 2 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 docs/design/memory-anchors-design.md diff --git a/docs/design/file-content-source-design.md b/docs/design/file-content-source-design.md index ccb168b..d984f3a 100644 --- a/docs/design/file-content-source-design.md +++ b/docs/design/file-content-source-design.md @@ -1,7 +1,11 @@ # File content source for search -**Status:** proposed +**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. 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 @@ -136,6 +140,12 @@ already provide the observation seam. - Not a document-management system: no file versions, no document lifecycle. - Lexical-first by default; vector is an optional future enhancement, not a requirement (per §4 finding). +- **No structured-unit parsing for now** (surveyed 2026-09-02): code-symbol + units (tree-sitter) and Markdown section anchors (mdast) are reliable in + principle, but document-side "concept units" have no deterministic parser — + prose structure lives at the semantic layer, not the syntax layer. We leave + an extractor protocol slot (crawler → per-type extractor → unit rows) and do + not implement it until a real gap or a mature tool demands it. ## 6. MVP path @@ -155,3 +165,9 @@ already provide the observation seam. - Scope auto-growth cap and decay (avoid unbounded scope growth). - Whether the file index should be per-project (`.nmg/`) or share the daemon store; per-project keeps it isolated and deletable. +- **Structured-unit evolution (deferred, protocol slot only)**: the current + index is whole-file blob. A future extractor layer could emit unit rows + (`path, kind, name, start_line, end_line`) for code (tree-sitter) and + Markdown (mdast heading sections), giving file:line anchors instead of + trigram fragments. Document "concept units" have no deterministic parser and + are deliberately not pursued; semantic understanding stays with embeddings. diff --git a/docs/design/memory-anchors-design.md b/docs/design/memory-anchors-design.md new file mode 100644 index 0000000..846bef5 --- /dev/null +++ b/docs/design/memory-anchors-design.md @@ -0,0 +1,182 @@ +# Memory anchors: bookmarks as a searchable source + +**Status:** proposed +**Updated:** 2026-09-02 + +This document supersedes the file-content-source design +([file-content-source-design.md](file-content-source-design.md)) as the owner of +"how an Agent reaches file content through NMG". It records a first-principles +redesign reached through an extended design discussion (surveyed 2026-09-02). + +## 1. Problem + +The Agent repeatedly "searches around" for things it already knows exist. NMG +memory stores *long-lived, low-churn* facts — "there is a budget mechanism". But +memory does not say *where* that mechanism lives in the current file tree, so +every session re-discovers file locations by hand (`grep` / `read` / `glob`). + +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 +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. + +## 2. First-principles reframing + +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 +``` + +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**, +not memory content and not a file-content replica. + +## 3. Decisions + +### 3.1 No file full-text search + +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 +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 +needs *pointers into it*. + +### 3.2 Anchors 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. + +```text +nmg search "budget" + ├── memory source: FTS over memory_records (existing) + └── anchor source: FTS over anchors (label/path) (new) +``` + +An anchor 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 | + +Anchors are searchable independently: even with no matching memory (or after a +memory is superseded), a matching anchor is still found. + +### 3.3 Anchors 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 +in the current file (exact match → position; fuzzy fallback → nearest match; +absent → anchor 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): +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). + +### 3.4 Markers are the index pointer between memory and anchor + +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: + +```jsonc +markers: [{ + "kind": "anchor_ref", + "attributes": { "anchorId": "…" } +}] +``` + +The marker is a *pointer*; the anchor 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. +- **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. + +## 4. Architecture + +```text +┌─────────────── search ────────────────────────────────┐ +│ query → memory hits (existing) + anchor hits (new) │ +└───────────────────────────┬───────────────────────────┘ + │ + ┌───────────────────┴───────────────────┐ + ▼ ▼ +┌───────────────┐ ┌───────────────────┐ +│ memory_records│ │ anchors │ +│ (LTG/STG) │ marker anchor_ref │ (path, snippet, │ +│ │ ──────────────────► │ label, memory_id)│ +└───────────────┘ └─────────┬─────────┘ + │ resolve snippet + ▼ + file (content host, + never indexed by NMG) +``` + +## 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 + 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 + tree-sitter, no mdast. Document "concept units" remain out of scope (no + deterministic parser; see the superseded design's structured-unit note). + +## 6. Relation to the superseded design + +[file-content-source-design.md](file-content-source-design.md) proposed a +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 +(`.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 + 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. + +## 8. Research basis (surveyed 2026-09-02) + +| Reference | What it validates | +| --- | --- | +| [haido DESIGN.md](https://github.com/lebac-svg/haido/blob/HEAD/docs/DESIGN.md) | Anchored memory with objective staleness (`hash_at_link`), not TTLs or LLM self-reflection; anchors drift/missing/moved; recall ranks anchors before full text. Closest full implementation to this design. | +| [gptme `_anchored.py`](https://github.com/gptme/gptme/blob/ae707fc8233e77d4da97fc74f94db1eaff1e381a/gptme/tools/_anchored.py) | Hash-anchored, content-based editing — content anchors survive edits, line numbers do not. | +| [agentic-bookmarks](https://github.com/super-mega-lab/agentic-bookmarks) | Durable bookmarks with self-healing anchors that survive refactors. | +| [ai-memory ARCHITECTURE](https://github.com/akitaonrails/ai-memory/blob/v1.8.0/docs/ARCHITECTURE.md) | Markdown wiki as source of truth, SQLite as derived index — validates "pointers, not replicas". | +| [quote-anchored citations ADR](https://zby.github.io/commonplace/reference/adr/023-quote-anchored-citations-for-code-grounded-reviews/) | Cite by quoted content, not line number, for code-grounded references. | From 4801e82a9e0630e567ea8bc110c14da857fe7a19 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:59:03 +0800 Subject: [PATCH 2/4] feat(anchors): memory anchors as independent searchable source Implements the memory-anchors design (docs/design/memory-anchors-design.md): file bookmarks attached to memories, searchable alongside memory. - store: anchors table + anchors_fts (label/snippet) in schema; remember() writes anchor rows and auto-appends anchor_ref markers (open markers channel, zero migration on existing data); searchAnchors/getAnchorsByIds in retrieval mixin. Duplicate memory writes do not duplicate anchors. - service: remember params accept anchors; search attaches resolved anchor hits (snippet relocated to line against projectDir, stale when absent). - CLI: --anchor PATH::SNIPPET[::LABEL] on remember; ANCHORS: section on human search output. - DSH plugin: nmg_remember accepts anchors; search surface renders anchor=path:line entries. - tests: anchors.test.ts covers write/markers/search/duplicate paths. Complexity gate passes; write path refactored into #planAnchorWrites / #insertAnchorRows / mergeMarkers to hold rememberInner at baseline. --- dsh/dsh-nmg/src/plugin/index.ts | 2 + src/cli/commands.ts | 28 +++++++- src/cli/main.ts | 31 ++++++-- src/cli/protocol.ts | 5 ++ src/cli/service.ts | 95 +++++++++++++++++++++++- src/core/store/retrieval.ts | 66 +++++++++++++++++ src/core/store/schema.ts | 38 ++++++++++ src/core/store/writes.ts | 69 +++++++++++++++++- src/core/types.ts | 55 ++++++++++++++ src/integration/agent-surface.ts | 11 ++- src/integration/search-projection.ts | 11 ++- tests/core/anchors.test.ts | 103 +++++++++++++++++++++++++++ 12 files changed, 502 insertions(+), 12 deletions(-) create mode 100644 tests/core/anchors.test.ts diff --git a/dsh/dsh-nmg/src/plugin/index.ts b/dsh/dsh-nmg/src/plugin/index.ts index 8272c73..061d69c 100644 --- a/dsh/dsh-nmg/src/plugin/index.ts +++ b/dsh/dsh-nmg/src/plugin/index.ts @@ -1155,6 +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.' }, }, required: [], }, @@ -1200,6 +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.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 be91f04..71524b5 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -202,6 +202,7 @@ export const NMG_CLI_COMMANDS: readonly CliCommandSpec[] = [ "opened-at", "related-memory", "recall-trigger", + "anchor", ], flags: [], usageDetail: `Remember options: @@ -222,7 +223,9 @@ export const NMG_CLI_COMMANDS: readonly CliCommandSpec[] = [ --write-source SOURCE Submission channel; defaults to user for the CLI --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`, + --content-hash HASH Optional external content hash + --anchor PATH::SNIPPET[::LABEL] Repeatable file bookmark: path, ::, content + snippet (relocation key), optional ::LABEL`, buildParams: rememberParams, }, { @@ -1021,11 +1024,34 @@ 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")), markers: externalMarker, projectDir: optionalResolvedPath(firstOption(values, "project-dir")), }) as unknown as NmgRememberParams; } +/** Parse repeatable `--anchor PATH::SNIPPET[:LABEL]` options into AnchorInput + * entries. The snippet is everything up to the next `::` (or the end); the + * optional label follows a second `::`. Paths must not contain `::`. */ +function parseAnchorOptions(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 }> = []; + 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"); + } + 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); + } + return anchors.length > 0 ? anchors : undefined; +} + function resolutionParams( values: OptionValues, action: "resolve" | "reopen", diff --git a/src/cli/main.ts b/src/cli/main.ts index 87b4b9f..191dbea 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -434,6 +434,29 @@ function parseOptions(args: readonly string[]): OptionValues { return { flags, options, positionals }; } +/** Append the FILES / ANCHORS 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, +): string[] { + const lines: string[] = []; + if (files && files.length > 0) { + lines.push("FILES:"); + for (const file of files) { + 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}`); + } + } + return lines; +} + function humanResult(value: unknown): string { const result = value as Record; if (result.action === "discover" && Array.isArray(result.agents)) { @@ -582,18 +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 }>; 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}`, ); - if (context.files && context.files.length > 0) { - lines.push("FILES:"); - for (const file of context.files) { - lines.push(`${file.path}\t${file.excerpt}`); - } - } + lines.push(...sourceSectionLines(context.files, context.anchors)); 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 a2f30d3..d59ac62 100644 --- a/src/cli/protocol.ts +++ b/src/cli/protocol.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import type { ActiveGraphBudget, + AnchorInput, ClaimOutcomeEvent, ClaimPosterior, MemoryActor, @@ -253,6 +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 + * project file. Persisted with the memory; searchable as an independent + * source. */ + anchors?: AnchorInput[]; projectDir?: string; } diff --git a/src/cli/service.ts b/src/cli/service.ts index 427c8df..96cacbe 100644 --- a/src/cli/service.ts +++ b/src/cli/service.ts @@ -1,4 +1,4 @@ -import { existsSync, statSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import { @@ -19,7 +19,14 @@ import { createNodeSummaryProviderFromEnv, drainNodeSummaries, } from "../integration/node-summarizer.ts"; -import type { LeafSummaryProvider, NodeSummaryProvider, RememberInput } from "../core/types.ts"; +import type { + AnchorInput, + AnchorHit, + AnchorRecord, + LeafSummaryProvider, + NodeSummaryProvider, + RememberInput, +} from "../core/types.ts"; import { NmgStore } from "../core/store.ts"; import { SessionActiveGraphRuntime, @@ -1380,8 +1387,17 @@ export class NmgService { if (projectDir) { fileHits = this.#searchProjectFiles(projectDir, query, options.limit ?? 8); } + // Anchor (bookmark) source: independent of projectDir — anchors 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), + projectDir, + ); const withFiles = (context: T): T => { if (fileHits.length > 0) context.files = fileHits; + if (anchorHits.length > 0) context.anchors = anchorHits; return context; }; const runOne = async (store: NmgStore, raw: string): Promise => { @@ -1500,6 +1516,55 @@ export class NmgService { } } + /** Search the anchor (bookmark) source across stores. Anchor 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); + return rows.map((row) => ({ + id: row.id, + path: row.path, + label: row.label, + kind: row.kind, + memoryId: row.memoryId, + snippet: row.snippet, + })); + } + + /** Resolve anchor 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; + const cache = new Map(); // absPath -> lines | null + const linesFor = (path: string): string[] | null => { + const abs = resolve(projectRoot, path); + if (cache.has(abs)) return cache.get(abs)!; + let lines: string[] | null = null; + try { + const content = readFileSync(abs, "utf8"); + lines = content.split(/\r?\n/); + } catch { + lines = null; + } + 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(); + // 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 }; + }); + } + #get(params: NmgGetParams): NmgMethodResult["get"] { const sharedStore = this.#getStore(); const localStore = params.projectDir @@ -1801,6 +1866,7 @@ function parseRememberParams(value: unknown): NmgRememberParams { sourceRef: optionalString(params, "sourceRef"), markers: optionalMarkers(params, "markers"), recallTriggers: optionalRecallTriggers(params), + anchors: optionalAnchors(params, "anchors"), unsafe: optionalBoolean(params, "unsafe"), projectDir: optionalString(params, "projectDir"), }; @@ -1837,6 +1903,31 @@ function optionalEvidenceSource( }; } +/** Parse the optional anchors array on a remember write. Each anchor needs a + * path and a snippet (the relocation key); label/kind are optional. */ +function optionalAnchors( + params: Record, + key: string, +): AnchorInput[] | 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`); + } + 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; + return { + path: requiredString(anchor, "path"), + snippet: requiredString(anchor, "snippet"), + label: optionalString(anchor, "label"), + kind: optionalString(anchor, "kind"), + }; + }); +} + function parseExportMemoriesParams(value: unknown): NmgExportMemoriesParams { const params = objectParams(value); return { diff --git a/src/core/store/retrieval.ts b/src/core/store/retrieval.ts index befd898..406fa37 100644 --- a/src/core/store/retrieval.ts +++ b/src/core/store/retrieval.ts @@ -54,6 +54,7 @@ import type { ActiveGraphBudget, ActiveGraphBudgetUsage, ActiveGraphSelection, + AnchorRecord, LeafBlock, HistoryRecord, MemoryContext, @@ -1640,6 +1641,71 @@ 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 + * 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[] { + 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) + LIMIT ?`, + ) + .all(expression, Math.max(1, Math.min(limit, 50))) as Array<{ + id: string; + path: string; + snippet: string; + label: string; + kind: string | null; + memory_id: string | null; + created_at: string; + }>; + return rows.map((row) => ({ + id: row.id, + path: row.path, + snippet: row.snippet, + label: row.label, + kind: row.kind ?? undefined, + memoryId: row.memory_id ?? undefined, + createdAt: row.created_at, + })); + } + + /** Load anchors by id (used when resolving anchor_ref markers on a memory). */ + getAnchorsByIds(ids: readonly string[]): AnchorRecord[] { + 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})`, + ) + .all(...ids) as Array<{ + id: string; + path: string; + snippet: string; + label: string; + kind: string | null; + memory_id: string | null; + created_at: string; + }>; + return rows.map((row) => ({ + id: row.id, + path: row.path, + snippet: row.snippet, + label: row.label, + kind: row.kind ?? undefined, + memoryId: row.memory_id ?? undefined, + createdAt: row.created_at, + })); + } + searchByVector( query: string, queryVector: readonly number[], diff --git a/src/core/store/schema.ts b/src/core/store/schema.ts index b820876..2ee4734 100644 --- a/src/core/store/schema.ts +++ b/src/core/store/schema.ts @@ -507,6 +507,44 @@ export function migrate(db: DatabaseSync): void { counter INTEGER NOT NULL DEFAULT 0 ); + -- Memory anchors (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 + -- 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 ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL, + snippet TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + kind TEXT, + 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( + label, + snippet, + path UNINDEXED, + content = 'anchors', + 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); + END; + CREATE TRIGGER IF NOT EXISTS anchors_fts_ad AFTER DELETE ON anchors BEGIN + INSERT INTO anchors_fts (anchors_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) + 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); + END; + CREATE INDEX IF NOT EXISTS idx_memory_records_node_tier ON memory_records(node_id, tier); CREATE INDEX IF NOT EXISTS idx_memory_records_tier_priority diff --git a/src/core/store/writes.ts b/src/core/store/writes.ts index dfa3b4b..037d76a 100644 --- a/src/core/store/writes.ts +++ b/src/core/store/writes.ts @@ -64,6 +64,12 @@ 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 + * module-level function so rememberInner's cyclomatic complexity stays flat. */ +function mergeMarkers(base: readonly MemoryMarker[] | undefined, extra: readonly MemoryMarker[]): MemoryMarker[] { + return [...(base ?? []), ...extra]; +} + export function withWrites(Base: TBase) { return class extends Base { // Base-class members (resolved at assembly time) @@ -492,6 +498,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 + // 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); let supersededNodeId: string | undefined; if (supersedesId) { const previous = this.db @@ -536,7 +548,7 @@ export function withWrites(Base: TBase) { predicateKey: input.predicateKey, extractMethod: input.extractMethod, claims: input.claims, - markers: input.markers, + markers: mergeMarkers(input.markers, anchorMarkers), recallTriggers: input.recallTriggers, tier: input.tier, importance: input.importance, @@ -569,6 +581,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); if (manageTransaction) this.db.exec("COMMIT"); const written = { history, node, memory }; return { @@ -583,6 +597,59 @@ 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<{ + id: string; + path: string; + snippet: string; + label: string; + kind?: string; + }>; + anchorMarkers: MemoryMarker[]; + } { + const anchorPlan: 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 id = randomUUID(); + anchorPlan.push({ + id, + path, + snippet, + label: String(anchor.label ?? "").trim(), + kind: anchor.kind?.trim() || undefined, + }); + anchorMarkers.push({ kind: "anchor_ref", attributes: { anchorId: id, path } }); + } + return { anchorPlan, anchorMarkers }; + } + + /** Insert planned anchor rows inside the memory-write transaction. */ + #insertAnchorRows( + anchorPlan: Array<{ id: string; path: string; snippet: string; label: string; kind?: string }>, + 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) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ); + const createdAt = new Date().toISOString(); + for (const plan of anchorPlan) { + insertAnchor.run(plan.id, plan.path, plan.snippet, plan.label, plan.kind ?? null, memoryId, createdAt); + } + } + /** * Mark a same-scope memory as superseded by a newer record (deterministic * supersession, the MemStrata/MemClaw write-time pattern): the stale record diff --git a/src/core/types.ts b/src/core/types.ts index 07d3948..77ec160 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -376,6 +376,10 @@ export interface RememberInput { expiresAt?: string; writeReason?: string; writeSource?: MemoryWriteSource; + /** Optional file anchors (bookmarks) attached to this memory. Each anchor + * 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[]; /** Disable per-phase timing for this write (default: enabled). */ perf?: boolean; } @@ -849,6 +853,53 @@ export interface FileHit { score: number; } +/** A file location a memory points into (a 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). */ + id: string; + /** Project-relative file path the anchor points into. */ + path: string; + /** Short content excerpt used for relocation, never a line number. */ + snippet: string; + /** Agent-written one-liner, searchable. */ + label: string; + /** Optional kind, e.g. "code" | "doc" | "note". */ + kind?: string; + /** Owning memory id, when the anchor was raised by a memory write. */ + memoryId?: string; + createdAt: string; +} + +/** Anchor input on a memory write (agent-supplied, active). */ +export interface AnchorInput { + path: string; + snippet: string; + label?: string; + kind?: string; +} + +/** Anchor hit surfaced by search — an anchor row plus its resolved line, or a + * staleness marker when the snippet no longer exists in the file. */ +export interface AnchorHit { + id: string; + path: string; + label: string; + kind?: string; + memoryId?: string; + /** The content snippet this anchor 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). */ + 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"; + export interface MemoryContext { results: MemorySearchResult[]; /** File-content source hits (bounded passive-scope FTS), separate from @@ -856,6 +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 + * memory. Each hit carries the resolved line in the current file, or a + * staleness marker when the snippet no longer exists. */ + anchors?: AnchorHit[]; /** 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 3c01bcc..5924cff 100644 --- a/src/integration/agent-surface.ts +++ b/src/integration/agent-surface.ts @@ -117,7 +117,15 @@ export function renderCompactSearchSurface( const fileLines = (context.files ?? []).map( (hit) => `- file=${hit.path}; excerpt=${hit.excerpt}`, ); - if (context.candidates.length === 0 && fileLines.length === 0) { + const anchorLines = (context.anchors ?? []).map((anchor) => { + const position = anchor.stale + ? "(stale)" + : anchor.line + ? `:${anchor.line}` + : ""; + return `- anchor=${anchor.path}${position}; label=${anchor.label || "(bookmark)"}`; + }); + if (context.candidates.length === 0 && fileLines.length === 0 && anchorLines.length === 0) { return options.emptyText ?? "No matching NMG memory found."; } const lines = context.candidates.map((candidate) => { @@ -138,6 +146,7 @@ export function renderCompactSearchSurface( options.preamble, ...lines, ...fileLines, + ...anchorLines, 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 7619365..86f6e4a 100644 --- a/src/integration/search-projection.ts +++ b/src/integration/search-projection.ts @@ -1,6 +1,11 @@ import { createHash } from "node:crypto"; -import type { FileHit, MemoryContext, MemorySearchResult } from "../core/types.ts"; +import type { + AnchorHit, + FileHit, + MemoryContext, + MemorySearchResult, +} from "../core/types.ts"; import type { SessionDisclosureLevel } from "../core/session-active-graph.ts"; import { logicalChainCount, logicalChainNames } from "./chain-projection.ts"; @@ -37,6 +42,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 + * lines (or marked stale) by the service layer. */ + anchors?: AnchorHit[]; } /** Agent-facing search projection. Exact records and evidence remain behind `nmg get`. */ @@ -58,6 +66,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 } : {}), }; } diff --git a/tests/core/anchors.test.ts b/tests/core/anchors.test.ts new file mode 100644 index 0000000..db7952e --- /dev/null +++ b/tests/core/anchors.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +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"; + +function withStore(run: (store: NmgStore) => void): void { + const directory = mkdtempSync(join(tmpdir(), "nmg-anchors-")); + const store = new NmgStore(join(directory, "nmg.sqlite")); + try { + run(store); + } finally { + store.close(); + rmSync(directory, { recursive: true, force: true }); + } +} + +test("remember with anchors writes rows and anchor_ref markers", () => { + withStore((store) => { + const result = store.remember({ + statement: "anchors are an independent searchable source", + nodeName: "anchor-test", + scope: { project: "smoke" }, + anchors: [ + { + path: "src/core/types.ts", + snippet: "export interface AnchorRecord", + label: "AnchorRecord type", + kind: "code", + }, + { path: "docs/design.md", snippet: "## 3. Decisions", label: "design section" }, + ], + }); + 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)); + assert.equal(ids.length, 2); + const byId = store.getAnchorsByIds(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"); + assert.equal(byPath?.memoryId, result.memory.id); + }); +}); + +test("searchAnchors matches label and snippet via FTS", () => { + withStore((store) => { + store.remember({ + statement: "budget mechanism exists", + nodeName: "anchor-budget", + scope: { project: "smoke" }, + anchors: [ + { + path: "docs/design/design.md", + snippet: "unified session budget", + label: "unified budget section", + }, + ], + }); + const byLabel = store.searchAnchors("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); + assert.equal(bySnippet.length, 1, "snippet term hits"); + }); +}); + +test("remember without anchors writes no rows and no markers", () => { + withStore((store) => { + const result = store.remember({ + statement: "plain memory without anchors", + nodeName: "anchor-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); + assert.equal(refs.length, 0); + assert.equal(store.searchAnchors("plain memory", 5).length, 0); + }); +}); + +test("duplicate memory write does not duplicate anchors", () => { + withStore((store) => { + const input = { + statement: "duplicate anchor memory", + nodeName: "anchor-dup", + scope: { project: "smoke" }, + anchors: [{ 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"); + }); +}); From d1101786700095d1b6688264be90e242f2237232 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:12:37 +0800 Subject: [PATCH 3/4] fix(lint): resolve eslint no-unused-vars and prettier formatting - remove unused AnchorRecord import in service - extract readLinesSafe module helper (no useless assignment) - prettier formatting on touched files --- src/cli/commands.ts | 8 ++++++-- src/cli/service.ts | 25 +++++++++++++------------ src/core/store/writes.ts | 23 ++++++++++++++++++++--- src/integration/agent-surface.ts | 6 +----- src/integration/search-projection.ts | 7 +------ 5 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 71524b5..7da433a 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1033,14 +1033,18 @@ function rememberParams(values: OptionValues): NmgRememberParams { /** Parse repeatable `--anchor PATH::SNIPPET[:LABEL]` options into AnchorInput * entries. The snippet is everything up to the next `::` (or the end); the * optional label follows a second `::`. Paths must not contain `::`. */ -function parseAnchorOptions(raw: unknown): Array<{ path: string; snippet: string; label?: string }> | undefined { +function parseAnchorOptions( + 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 }> = []; 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"); + throw new Error( + "--anchor must be PATH::SNIPPET (path then :: then content snippet), optionally ::LABEL", + ); } const path = parts[0]!.trim(); const snippet = parts[1]!.trim(); diff --git a/src/cli/service.ts b/src/cli/service.ts index 96cacbe..ca9e764 100644 --- a/src/cli/service.ts +++ b/src/cli/service.ts @@ -22,7 +22,6 @@ import { import type { AnchorInput, AnchorHit, - AnchorRecord, LeafSummaryProvider, NodeSummaryProvider, RememberInput, @@ -1541,13 +1540,7 @@ export class NmgService { const linesFor = (path: string): string[] | null => { const abs = resolve(projectRoot, path); if (cache.has(abs)) return cache.get(abs)!; - let lines: string[] | null = null; - try { - const content = readFileSync(abs, "utf8"); - lines = content.split(/\r?\n/); - } catch { - lines = null; - } + const lines = readLinesSafe(abs); cache.set(abs, lines); return lines; }; @@ -1905,10 +1898,7 @@ function optionalEvidenceSource( /** Parse the optional anchors array on a remember write. Each anchor needs a * path and a snippet (the relocation key); label/kind are optional. */ -function optionalAnchors( - params: Record, - key: string, -): AnchorInput[] | undefined { +function optionalAnchors(params: Record, key: string): AnchorInput[] | undefined { const value = params[key]; if (value === undefined) return undefined; if (!Array.isArray(value) || value.length > 10) { @@ -1928,6 +1918,17 @@ function optionalAnchors( }); } +/** Read a file's lines, or null when unreadable/missing. Used by anchor + * snippet relocation. */ +function readLinesSafe(absPath: string): string[] | null { + try { + const content = readFileSync(absPath, "utf8"); + return content.split(/\r?\n/); + } catch { + return null; + } +} + function parseExportMemoriesParams(value: unknown): NmgExportMemoriesParams { const params = objectParams(value); return { diff --git a/src/core/store/writes.ts b/src/core/store/writes.ts index 037d76a..5af7a22 100644 --- a/src/core/store/writes.ts +++ b/src/core/store/writes.ts @@ -66,7 +66,10 @@ import { type ScopeWriteIndexRow, writeTokens } from "./scope-write-index.ts"; /** Merge caller markers with auto-generated anchor_ref markers. Kept as a * module-level function so rememberInner's cyclomatic complexity stays flat. */ -function mergeMarkers(base: readonly MemoryMarker[] | undefined, extra: readonly MemoryMarker[]): MemoryMarker[] { +function mergeMarkers( + base: readonly MemoryMarker[] | undefined, + extra: readonly MemoryMarker[], +): MemoryMarker[] { return [...(base ?? []), ...extra]; } @@ -636,7 +639,13 @@ export function withWrites(Base: TBase) { /** Insert planned anchor rows inside the memory-write transaction. */ #insertAnchorRows( - anchorPlan: Array<{ id: string; path: string; snippet: string; label: string; kind?: string }>, + anchorPlan: Array<{ + id: string; + path: string; + snippet: string; + label: string; + kind?: string; + }>, memoryId: string, ): void { if (anchorPlan.length === 0) return; @@ -646,7 +655,15 @@ export function withWrites(Base: TBase) { ); const createdAt = new Date().toISOString(); for (const plan of anchorPlan) { - insertAnchor.run(plan.id, plan.path, plan.snippet, plan.label, plan.kind ?? null, memoryId, createdAt); + insertAnchor.run( + plan.id, + plan.path, + plan.snippet, + plan.label, + plan.kind ?? null, + memoryId, + createdAt, + ); } } diff --git a/src/integration/agent-surface.ts b/src/integration/agent-surface.ts index 5924cff..54fd80d 100644 --- a/src/integration/agent-surface.ts +++ b/src/integration/agent-surface.ts @@ -118,11 +118,7 @@ export function renderCompactSearchSurface( (hit) => `- file=${hit.path}; excerpt=${hit.excerpt}`, ); const anchorLines = (context.anchors ?? []).map((anchor) => { - const position = anchor.stale - ? "(stale)" - : anchor.line - ? `:${anchor.line}` - : ""; + const position = anchor.stale ? "(stale)" : anchor.line ? `:${anchor.line}` : ""; return `- anchor=${anchor.path}${position}; label=${anchor.label || "(bookmark)"}`; }); if (context.candidates.length === 0 && fileLines.length === 0 && anchorLines.length === 0) { diff --git a/src/integration/search-projection.ts b/src/integration/search-projection.ts index 86f6e4a..df34980 100644 --- a/src/integration/search-projection.ts +++ b/src/integration/search-projection.ts @@ -1,11 +1,6 @@ import { createHash } from "node:crypto"; -import type { - AnchorHit, - FileHit, - MemoryContext, - MemorySearchResult, -} from "../core/types.ts"; +import type { AnchorHit, FileHit, MemoryContext, MemorySearchResult } from "../core/types.ts"; import type { SessionDisclosureLevel } from "../core/session-active-graph.ts"; import { logicalChainCount, logicalChainNames } from "./chain-projection.ts"; From a7126d6531a97abf434e0860e54c49d8d595c6be Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:20:24 +0800 Subject: [PATCH 4/4] chore: add pull request template with completion checklist Template guides PR description (what/why/changes) and pre-merge checks: local quality (verify:static, test:product), RCP (board goal, agent:verify reconcile, owned files only), and CI completion via the CI Status Snapshot (.nmg-ci/status.json conclusion=success, failures=[]) instead of polling individual jobs. Aligns with ci-cd-and-quality.md proof-before-trust. --- .github/pull_request_template.md | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..25b0a49 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,39 @@ +## 变更描述 + + + +**What**(改了什么): + +**Why**(为什么改 / 解决什么问题): + +**Changes**(关键改动点,按文件或模块列出): + +--- + +## 完成检查项 + + + +### 本地质量检查 + +- [ ] `npm run verify:static` 通过(build / package:check / tsc / lint / format:check / docs:check / agent:context:check / complexity:gate) +- [ ] `npm run test:product` 通过(或按改动路由跑 `npm run agent:verify -- <路径>`) +- [ ] 文档改动跑过 `npm run docs:check`;决策/设计改动遵循 doc-maintenance 规范 +- [ ] 新代码方法圈复杂度不超阈值(CodeFactor / `npm run complexity:gate`) + +### RCP(Repository Control Plane) + +- [ ] 首个实质写入前已在 `repo-development` 黑板登记 in-flight goal(条目已 resolve) +- [ ] `npm run agent:verify -- <改动路径>` 跑过 reconcile,`.nmg/verification/latest.json` 覆盖改动路由 +- [ ] 只提交本 PR 拥有的文件;未吞并行 Agent 的暂存/工作树改动 + +### CI 完成确认 + + +- [ ] CI Status Snapshot(`.nmg-ci/status.json`)结论为 `workflow.conclusion: "success"` 且 `failures: []` + —— 或 `gh pr checks | Select-String "All checks passed"` 出现且为 pass +- [ ] CodeFactor 通过 + +> CI Status Snapshot 是 GitHub 状态的只读观察(`authority: observation-only`), +> 不是授权或合并决定;合并仍需显式操作。