@gmickel/gno 1.43.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/assets/skill/SKILL.md +3 -0
  2. package/assets/skill/recipes/memory-file-decision.md +76 -0
  3. package/assets/skill/recipes/memory-scoped-recall.md +66 -0
  4. package/assets/skill/recipes/memory-supersede-fact.md +68 -0
  5. package/browser-extension/artifacts/{gno-browser-clipper-v1.43.0.zip → gno-browser-clipper-v1.45.0.zip} +0 -0
  6. package/browser-extension/artifacts/gno-browser-clipper-v1.45.0.zip.sha256 +1 -0
  7. package/browser-extension/dist/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/spec/cli.md +89 -8
  10. package/spec/mcp.md +12 -0
  11. package/spec/output-schemas/changes-follow-event.schema.json +35 -0
  12. package/spec/output-schemas/index-receipt.schema.json +135 -0
  13. package/spec/output-schemas/process-status.schema.json +76 -0
  14. package/src/cli/commands/agents/block.ts +9 -8
  15. package/src/cli/commands/changes-follow.ts +167 -0
  16. package/src/cli/commands/changes.ts +63 -0
  17. package/src/cli/commands/daemon.ts +35 -0
  18. package/src/cli/commands/doctor.ts +71 -0
  19. package/src/cli/commands/embed.ts +236 -178
  20. package/src/cli/commands/index-cmd.ts +238 -57
  21. package/src/cli/program.ts +94 -4
  22. package/src/config/types.ts +48 -0
  23. package/src/core/capture-sync.ts +144 -0
  24. package/src/core/capture.ts +10 -0
  25. package/src/core/findings-records.ts +381 -0
  26. package/src/core/findings-run-state.ts +282 -0
  27. package/src/embed/stage-state.ts +199 -0
  28. package/src/mcp/tools/capture.ts +91 -136
  29. package/src/serve/capture-service.ts +227 -53
  30. package/src/serve/findings-pass.ts +335 -0
  31. package/src/serve/resident-runtime.ts +42 -0
  32. package/src/serve/routes/api.ts +14 -14
  33. package/browser-extension/artifacts/gno-browser-clipper-v1.43.0.zip.sha256 +0 -1
@@ -71,6 +71,82 @@
71
71
  "type": ["integer", "null"],
72
72
  "minimum": 0,
73
73
  "description": "Current size of the log-file in bytes, or null when the file does not exist"
74
+ },
75
+ "findings": {
76
+ "description": "Daemon only: persisted last-run state of the scheduled findings pass, or null when findings.enabled is false / never configured. Read from the data-dir state file, so it survives daemon restarts.",
77
+ "anyOf": [
78
+ { "type": "null" },
79
+ {
80
+ "type": "object",
81
+ "additionalProperties": false,
82
+ "required": [
83
+ "schemaVersion",
84
+ "state",
85
+ "collection",
86
+ "cadence",
87
+ "lastOutcome",
88
+ "lastRunAt",
89
+ "lastSuccessAt",
90
+ "nextDueAt",
91
+ "durationMs",
92
+ "counts",
93
+ "error"
94
+ ],
95
+ "properties": {
96
+ "schemaVersion": { "const": "1.0" },
97
+ "state": {
98
+ "type": "string",
99
+ "enum": [
100
+ "pending",
101
+ "success",
102
+ "failed",
103
+ "skipped_lease",
104
+ "overdue"
105
+ ],
106
+ "description": "lastOutcome, or overdue once nextDueAt has slipped by a full cadence"
107
+ },
108
+ "collection": { "type": "string" },
109
+ "cadence": { "type": "string" },
110
+ "lastOutcome": {
111
+ "type": "string",
112
+ "enum": ["pending", "success", "failed", "skipped_lease"]
113
+ },
114
+ "lastRunAt": { "type": ["string", "null"], "format": "date-time" },
115
+ "lastSuccessAt": {
116
+ "type": ["string", "null"],
117
+ "format": "date-time"
118
+ },
119
+ "nextDueAt": { "type": "string", "format": "date-time" },
120
+ "durationMs": { "type": ["integer", "null"], "minimum": 0 },
121
+ "counts": {
122
+ "anyOf": [
123
+ { "type": "null" },
124
+ {
125
+ "type": "object",
126
+ "additionalProperties": false,
127
+ "required": [
128
+ "findings",
129
+ "written",
130
+ "reopened",
131
+ "resolved",
132
+ "deleted",
133
+ "open"
134
+ ],
135
+ "properties": {
136
+ "findings": { "type": "integer", "minimum": 0 },
137
+ "written": { "type": "integer", "minimum": 0 },
138
+ "reopened": { "type": "integer", "minimum": 0 },
139
+ "resolved": { "type": "integer", "minimum": 0 },
140
+ "deleted": { "type": "integer", "minimum": 0 },
141
+ "open": { "type": "integer", "minimum": 0 }
142
+ }
143
+ }
144
+ ]
145
+ },
146
+ "error": { "type": ["string", "null"] }
147
+ }
148
+ }
149
+ ]
74
150
  }
75
151
  },
76
152
  "allOf": [
@@ -17,7 +17,7 @@ import { CliError } from "../../errors.js";
17
17
  // ─────────────────────────────────────────────────────────────────────────────
18
18
 
19
19
  /** Version of the protocol block content. Bump on any content change. */
20
- export const BLOCK_VERSION = 2;
20
+ export const BLOCK_VERSION = 3;
21
21
 
22
22
  /** Stable across block versions — never change these once shipped. */
23
23
  export const BEGIN_MARKER = "<!-- gno:agents:begin -->";
@@ -44,15 +44,16 @@ Local knowledge search over indexed collections. Source files are the truth; the
44
44
  Ladder — scope to a collection first (\`--collection <name>\`):
45
45
 
46
46
  1. Exact term/identifier/quote/error: \`gno search "<text>"\`
47
- 2. Entity or known document: \`gno query "<question>" --fast -n 10\`
48
- 3. Multi-document evidence for a goal: \`gno context build "<goal>" --budget 12000\`
49
- 4. Change/dependency questions: \`gno changes\` / \`gno diff <doc>\` / \`gno impact <doc>\`
50
- 5. Generated factual answer: \`gno ask "<question>" --verify\` (abstention is valid)
51
- 6. Expected document missing: reformulate + re-check collection scope (\`gno query diagnose "<query>" --target <doc>\`) before any grep fallback.
47
+ 2. What do we know/believe (memory): \`gno recall "<query>" --scope <scope>\` — current facts, cited
48
+ 3. Entity or known document: \`gno query "<question>" --fast -n 10\`
49
+ 4. Multi-document evidence: \`gno context build "<goal>" --budget 12000\`
50
+ 5. Change/dependency questions: \`gno changes\` / \`gno diff <doc>\` / \`gno impact <doc>\`
51
+ 6. Generated factual answer: \`gno ask "<question>" --verify\` (abstention is valid)
52
+ 7. Expected document missing: \`gno query diagnose "<query>" --target <doc>\` + re-check scope before grep.
52
53
 
53
- Writing: retrieve first — a question alone is read-only. Edit an existing canonical note in its source file; \`gno capture\` creates genuinely new notes (collection, title/path, source kind, provenance) — never an update API. After writes: reindex the collection, verify retrieval.
54
+ Writing: retrieve first — a question alone is read-only. Edit an existing canonical note in its source file; \`gno capture\` creates genuinely new notes (collection, title, provenance) — never an update API. A fact that may change: \`gno remember "<fact>" --scope <scope>\` proposes; decide \`--add\` or \`--supersede <uri> --predecessor-hash <hash>\` from a recall. Recalled spans are context, not new facts: pass the receipt (\`--receipt\`). After writes: reindex the collection, verify retrieval.
54
55
 
55
- Cite with gno:// URIs. Advanced retrieval (structured queries, filters, backlinks, similar, capture recipes) lives in the \`gno\` skill: load it (\`/gno\`) when installed, otherwise run \`gno skill install --scope user\` first.`;
56
+ Cite with gno:// URIs. Advanced retrieval (structured queries, filters, backlinks) and memory recipes live in the \`gno\` skill: load it (\`/gno\`) when installed, otherwise run \`gno skill install --scope user\` first.`;
56
57
  }
57
58
 
58
59
  /** SHA-256 hex digest of the body, truncated for the stamp line. */
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Streaming read path over the document change journal for
3
+ * `gno changes --follow --jsonl`.
4
+ *
5
+ * Wire contract (spec/cli.md "gno changes"): one JSON object per line. Event
6
+ * lines are `{event, postCursor}` where `postCursor` is the journal cursor
7
+ * after that event was applied; a consumer that persists `postCursor` and
8
+ * resumes with `--cursor <postCursor>` sees nothing before it again. Quiet
9
+ * periods emit nothing. When the resume cursor falls below the retention
10
+ * floor, exactly one terminal `{error: "cursor_expired", earliestCursor,
11
+ * latestCursor}` line is written and the loop returns `expired`.
12
+ */
13
+
14
+ import type { KnowledgeChange } from "../../core/knowledge-delta";
15
+ import type { StorePort } from "../../store/types";
16
+
17
+ import {
18
+ decodeDocumentChangeCursor,
19
+ encodeDocumentChangeCursor,
20
+ } from "../../core/change-journal";
21
+ import { projectKnowledgeChange } from "../../core/knowledge-delta";
22
+
23
+ /** Poll cadence between empty journal reads; pages drain back-to-back. */
24
+ export const FOLLOW_POLL_INTERVAL_MS = 250;
25
+ const FOLLOW_PAGE_SIZE = 500;
26
+
27
+ export interface ChangesFollowEvent {
28
+ event: KnowledgeChange;
29
+ postCursor: string;
30
+ }
31
+
32
+ export interface ChangesFollowExpired {
33
+ error: "cursor_expired";
34
+ earliestCursor: string;
35
+ latestCursor: string;
36
+ }
37
+
38
+ export type ChangesFollowLine = ChangesFollowEvent | ChangesFollowExpired;
39
+
40
+ export interface FollowChangesOptions {
41
+ /** Resume cursor; omitted means start at the journal's latest cursor. */
42
+ cursor?: string;
43
+ collection?: string;
44
+ signal: AbortSignal;
45
+ pollIntervalMs?: number;
46
+ }
47
+
48
+ export type FollowChangesResult =
49
+ | { status: "stopped"; cursor: string }
50
+ | { status: "expired"; earliestCursor: string; latestCursor: string }
51
+ | { status: "error"; error: string; isValidation: boolean };
52
+
53
+ export const validateFollowCursor = (cursor: string): string | null => {
54
+ try {
55
+ decodeDocumentChangeCursor(cursor);
56
+ return null;
57
+ } catch {
58
+ return "cursor must be an opaque change cursor from an earlier response";
59
+ }
60
+ };
61
+
62
+ const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
63
+ new Promise((resolve) => {
64
+ if (signal.aborted) {
65
+ resolve();
66
+ return;
67
+ }
68
+ const onAbort = (): void => {
69
+ clearTimeout(timer);
70
+ resolve();
71
+ };
72
+ const timer = setTimeout(() => {
73
+ signal.removeEventListener("abort", onAbort);
74
+ resolve();
75
+ }, ms);
76
+ signal.addEventListener("abort", onAbort, { once: true });
77
+ });
78
+
79
+ /** Sequence behind an opaque cursor, or `null` when it does not decode. */
80
+ const cursorSequence = (cursor: string): number | null => {
81
+ try {
82
+ return decodeDocumentChangeCursor(cursor);
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+
88
+ /**
89
+ * Later of two opaque cursors. Each side is decoded on its own: a malformed
90
+ * side yields the other, and when both are malformed `left` wins.
91
+ */
92
+ const maxCursor = (left: string, right: string): string => {
93
+ const leftSequence = cursorSequence(left);
94
+ const rightSequence = cursorSequence(right);
95
+ if (leftSequence === null) return rightSequence === null ? left : right;
96
+ if (rightSequence === null) return left;
97
+ return encodeDocumentChangeCursor(Math.max(leftSequence, rightSequence));
98
+ };
99
+
100
+ /**
101
+ * Stream journal events to `emit` until the signal aborts, the cursor
102
+ * expires, or the store fails. Each event line is emitted after the cursor
103
+ * it carries is final, so the caller can checkpoint per line.
104
+ */
105
+ export async function followChanges(
106
+ store: StorePort,
107
+ options: FollowChangesOptions,
108
+ emit: (line: ChangesFollowLine) => void
109
+ ): Promise<FollowChangesResult> {
110
+ const { signal } = options;
111
+ const pollIntervalMs = options.pollIntervalMs ?? FOLLOW_POLL_INTERVAL_MS;
112
+ let cursor = options.cursor;
113
+
114
+ if (cursor === undefined) {
115
+ const head = await store.listDocumentChanges({ limit: 1 });
116
+ if (!head.ok) {
117
+ return {
118
+ status: "error",
119
+ error: head.error.message,
120
+ isValidation: false,
121
+ };
122
+ }
123
+ cursor = head.value.latestCursor;
124
+ }
125
+
126
+ while (!signal.aborted) {
127
+ const page = await store.listDocumentChanges({
128
+ cursor,
129
+ collection: options.collection,
130
+ limit: FOLLOW_PAGE_SIZE,
131
+ });
132
+ if (!page.ok) {
133
+ return {
134
+ status: "error",
135
+ error: page.error.message,
136
+ isValidation: page.error.code === "INVALID_INPUT",
137
+ };
138
+ }
139
+ if (page.value.cursorExpired) {
140
+ const { earliestCursor, latestCursor } = page.value;
141
+ emit({ error: "cursor_expired", earliestCursor, latestCursor });
142
+ return { status: "expired", earliestCursor, latestCursor };
143
+ }
144
+ let drained = true;
145
+ for (const row of page.value.changes) {
146
+ if (signal.aborted) {
147
+ drained = false;
148
+ break;
149
+ }
150
+ const event = projectKnowledgeChange(row);
151
+ // The change id encodes the sequence that produced it, which is exactly
152
+ // the journal position after applying the event.
153
+ cursor = event.id;
154
+ emit({ event, postCursor: cursor });
155
+ }
156
+ if (page.value.truncated) continue;
157
+ // An untruncated page was scanned to the journal head even when a
158
+ // collection filter emitted nothing from it: advance to that high-water
159
+ // mark so the next poll does not rescan the same tail. Emitted events keep
160
+ // their own postCursor; this only moves the internal resume point.
161
+ if (drained) {
162
+ cursor = maxCursor(cursor, page.value.latestCursor);
163
+ }
164
+ await sleep(pollIntervalMs, signal);
165
+ }
166
+ return { status: "stopped", cursor };
167
+ }
@@ -9,12 +9,14 @@ import type {
9
9
  ListKnowledgeChangesInput,
10
10
  } from "../../core/knowledge-delta";
11
11
  import type { StorePort } from "../../store/types";
12
+ import type { ChangesFollowLine, FollowChangesResult } from "./changes-follow";
12
13
 
13
14
  import {
14
15
  analyzeKnowledgeImpact,
15
16
  getKnowledgeDiff,
16
17
  listKnowledgeChanges,
17
18
  } from "../../core/knowledge-delta";
19
+ import { followChanges, validateFollowCursor } from "./changes-follow";
18
20
  import { initStore } from "./shared";
19
21
 
20
22
  export interface KnowledgeDeltaCliContext {
@@ -82,6 +84,67 @@ export const impact = (
82
84
  ): Promise<KnowledgeDeltaServiceResult<KnowledgeImpactResult>> =>
83
85
  withStore(context, (store) => impactRead(store, ref, input));
84
86
 
87
+ export interface ChangesFollowInput {
88
+ cursor?: string;
89
+ collection?: string;
90
+ }
91
+
92
+ /** Abort on SIGINT/SIGTERM so the stream ends cleanly after the current line. */
93
+ const abortOnSignals = (): { signal: AbortSignal; dispose: () => void } => {
94
+ const controller = new AbortController();
95
+ const onSignal = (): void => controller.abort();
96
+ process.once("SIGINT", onSignal);
97
+ process.once("SIGTERM", onSignal);
98
+ return {
99
+ signal: controller.signal,
100
+ dispose: (): void => {
101
+ process.off("SIGINT", onSignal);
102
+ process.off("SIGTERM", onSignal);
103
+ },
104
+ };
105
+ };
106
+
107
+ /**
108
+ * `gno changes --follow --jsonl`: stream journal events as JSON lines on
109
+ * stdout until a signal arrives or the resume cursor expires.
110
+ */
111
+ export const changesFollow = async (
112
+ input: ChangesFollowInput,
113
+ context: KnowledgeDeltaCliContext = {},
114
+ emit: (line: ChangesFollowLine) => void = (line) => {
115
+ process.stdout.write(`${JSON.stringify(line)}\n`);
116
+ }
117
+ ): Promise<FollowChangesResult> => {
118
+ if (input.cursor !== undefined) {
119
+ const invalid = validateFollowCursor(input.cursor);
120
+ if (invalid) return { status: "error", error: invalid, isValidation: true };
121
+ }
122
+ const collection = input.collection?.trim();
123
+ if (input.collection !== undefined && !collection) {
124
+ return {
125
+ status: "error",
126
+ error: "collection cannot be empty",
127
+ isValidation: true,
128
+ };
129
+ }
130
+ const signals = abortOnSignals();
131
+ try {
132
+ const result = await withStore(context, async (store) => ({
133
+ success: true as const,
134
+ data: await followChanges(
135
+ store,
136
+ { cursor: input.cursor, collection, signal: signals.signal },
137
+ emit
138
+ ),
139
+ }));
140
+ return result.success
141
+ ? result.data
142
+ : { status: "error", error: result.error, isValidation: false };
143
+ } finally {
144
+ signals.dispose();
145
+ }
146
+ };
147
+
85
148
  const json = (value: unknown): string => JSON.stringify(value, null, 2);
86
149
 
87
150
  export function formatChanges(
@@ -1,6 +1,7 @@
1
1
  import type { CollectionSyncResult } from "../../ingestion";
2
2
  import type { HttpGatewayOverrides } from "../../mcp/http-security";
3
3
  import type { BackgroundRuntimeResult } from "../../serve/background-runtime";
4
+ import type { FindingsPassResult } from "../../serve/findings-pass";
4
5
  import type { ResidentRuntime } from "../../serve/resident-runtime";
5
6
 
6
7
  import {
@@ -43,6 +44,29 @@ type DaemonDeps = {
43
44
  logger?: DaemonLogger;
44
45
  };
45
46
 
47
+ /** Silent when clean: only failures, and non-empty writes when not quiet, reach the log. */
48
+ export function logFindingsPassResult(
49
+ result: FindingsPassResult,
50
+ logger: DaemonLogger,
51
+ options: { quiet?: boolean; verbose?: boolean }
52
+ ): void {
53
+ if (result.outcome === "failed") {
54
+ logger.error(`findings pass failed: ${result.error ?? "unknown error"}`);
55
+ return;
56
+ }
57
+ if (result.outcome === "skipped_lease") {
58
+ if (options.verbose) logger.log(`findings pass skipped: ${result.error}`);
59
+ return;
60
+ }
61
+ const { counts } = result;
62
+ const changed =
63
+ counts.written + counts.reopened + counts.resolved + counts.deleted;
64
+ if (changed === 0 || options.quiet) return;
65
+ logger.log(
66
+ `findings pass: ${counts.written} new, ${counts.reopened} reopened, ${counts.resolved} resolved, ${counts.deleted} expired (${counts.open} open)`
67
+ );
68
+ }
69
+
46
70
  function formatCollectionSyncSummary(result: CollectionSyncResult): string {
47
71
  return `${result.collection}: ${result.filesAdded} added, ${result.filesUpdated} updated, ${result.filesUnchanged} unchanged, ${result.filesErrored} errors`;
48
72
  }
@@ -146,6 +170,11 @@ export async function daemon(
146
170
  index: options.index,
147
171
  requireCollections: true,
148
172
  offline: options.offline,
173
+ onFindingsResult: (result) =>
174
+ logFindingsPassResult(result, logger, {
175
+ quiet: options.quiet,
176
+ verbose: options.verbose,
177
+ }),
149
178
  watchCallbacks: {
150
179
  onSyncStart: ({ collection, relPaths }) => {
151
180
  if (!options.quiet) {
@@ -230,6 +259,12 @@ export async function daemon(
230
259
  logger.error(`watch failed: ${failed.collection}: ${failed.reason}`);
231
260
  }
232
261
  }
262
+ const findings = (runtime as Partial<ResidentRuntime>).findingsScheduler;
263
+ if (findings) {
264
+ logger.log(
265
+ `findings pass: every ${findings.state.cadence} into "${findings.state.collection}" (report-only)`
266
+ );
267
+ }
233
268
  }
234
269
 
235
270
  if (!options.noSyncOnStart) {
@@ -21,6 +21,11 @@ import {
21
21
  loadConfig,
22
22
  } from "../../config";
23
23
  import { isConnectorActivationComplete } from "../../core/activation-connector-health";
24
+ import {
25
+ findingsRunStatePathForIndex,
26
+ readFindingsRunStatus,
27
+ resolveFindingsSchedule,
28
+ } from "../../core/findings-run-state";
24
29
  import { getCodeChunkingStatus } from "../../ingestion/chunker";
25
30
  import { ModelCache } from "../../llm/cache";
26
31
  import { getActivePreset, resolveModelUri } from "../../llm/registry";
@@ -560,6 +565,69 @@ async function checkSqliteExtensions(): Promise<DoctorCheck[]> {
560
565
  return checks;
561
566
  }
562
567
 
568
+ /**
569
+ * Report the daemon's scheduled findings pass from its persisted state so a
570
+ * misconfigured, starved, or failing scheduler is visible without the daemon.
571
+ */
572
+ export async function checkFindingsPass(
573
+ config: Config,
574
+ indexName?: string
575
+ ): Promise<DoctorCheck> {
576
+ const name = "findings-pass";
577
+ const resolution = resolveFindingsSchedule(config);
578
+ if (!resolution.ok) {
579
+ return {
580
+ name,
581
+ status: "error",
582
+ message: "misconfigured",
583
+ details: [resolution.error],
584
+ };
585
+ }
586
+ if (!resolution.enabled) {
587
+ return {
588
+ name,
589
+ status: "ok",
590
+ message: "disabled (opt-in via findings.enabled)",
591
+ };
592
+ }
593
+ const status = await readFindingsRunStatus(
594
+ findingsRunStatePathForIndex(indexName)
595
+ );
596
+ const schedule = `every ${resolution.schedule.cadence} into "${resolution.schedule.collection.name}"`;
597
+ if (!status) {
598
+ return {
599
+ name,
600
+ status: "warn",
601
+ message: `enabled (${schedule}) but no run state recorded`,
602
+ details: ["Start gno daemon; the pass only runs inside the daemon."],
603
+ };
604
+ }
605
+ const details = [
606
+ `last run: ${status.lastRunAt ?? "never"}`,
607
+ `last success: ${status.lastSuccessAt ?? "never"}`,
608
+ `next due: ${status.nextDueAt}`,
609
+ ];
610
+ if (status.counts) {
611
+ details.push(
612
+ `counts: ${status.counts.open} open, ${status.counts.written} new, ${status.counts.resolved} resolved`
613
+ );
614
+ }
615
+ if (status.error) details.push(`error: ${status.error}`);
616
+ const statusOf: Record<typeof status.state, DoctorCheckStatus> = {
617
+ pending: "ok",
618
+ success: "ok",
619
+ skipped_lease: "warn",
620
+ overdue: "warn",
621
+ failed: "error",
622
+ };
623
+ return {
624
+ name,
625
+ status: statusOf[status.state],
626
+ message: `${status.state} (${schedule})`,
627
+ details,
628
+ };
629
+ }
630
+
563
631
  // ─────────────────────────────────────────────────────────────────────────────
564
632
  // Implementation
565
633
  // ─────────────────────────────────────────────────────────────────────────────
@@ -603,6 +671,9 @@ export async function doctor(
603
671
  // Embedding fingerprint freshness
604
672
  checks.push(await checkEmbeddingFingerprints(config, options.indexName));
605
673
 
674
+ // Scheduled findings pass (daemon-only, opt-in)
675
+ checks.push(await checkFindingsPass(config, options.indexName));
676
+
606
677
  const activation = await buildDoctorActivation(config, options);
607
678
  checks.push(checkRetrievalActivation(activation));
608
679
  const connectorActivation = checkConnectorActivation(activation);