@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
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Scheduled findings pass: config resolution and persisted last-run state.
3
+ *
4
+ * The state file lives next to the index database (data dir, never inside a
5
+ * collection) so `gno daemon --status` and `gno doctor` can read it without
6
+ * contacting the daemon. Writes are atomic (temp + rename, mode 0600).
7
+ *
8
+ * @module src/core/findings-run-state
9
+ */
10
+
11
+ // node:fs/promises: atomic rename/unlink and temp-dir creation have no Bun equivalent.
12
+ import { chmod, mkdtemp, rename, rmdir, unlink } from "node:fs/promises";
13
+ // node:path: dirname/join/basename have no Bun path utilities.
14
+ import { basename, dirname, join } from "node:path";
15
+
16
+ import type { Collection, Config } from "../config/types";
17
+
18
+ import { getIndexDbPath } from "../app/constants";
19
+ import { parseFindingsCadenceMs } from "../config/types";
20
+
21
+ export const FINDINGS_RUN_STATE_SCHEMA_VERSION = "1.0";
22
+
23
+ /** Outcome of the most recent attempt. `pending` = scheduled, never attempted. */
24
+ export type FindingsRunOutcome =
25
+ | "pending"
26
+ | "success"
27
+ | "failed"
28
+ | "skipped_lease";
29
+
30
+ /** Reader-facing state: the last outcome, or `overdue` when the next due time slipped. */
31
+ export type FindingsRunState = FindingsRunOutcome | "overdue";
32
+
33
+ export interface FindingsRunCounts {
34
+ /** Findings the audit reported this run. */
35
+ findings: number;
36
+ /** New records written. */
37
+ written: number;
38
+ /** Previously resolved records reopened. */
39
+ reopened: number;
40
+ /** Open records marked resolved. */
41
+ resolved: number;
42
+ /** Records deleted by retention. */
43
+ deleted: number;
44
+ /** Open records after the run. */
45
+ open: number;
46
+ }
47
+
48
+ export interface FindingsRunStateRecord {
49
+ schemaVersion: typeof FINDINGS_RUN_STATE_SCHEMA_VERSION;
50
+ collection: string;
51
+ cadence: string;
52
+ lastOutcome: FindingsRunOutcome;
53
+ /** Start of the most recent attempt (any outcome), or null before the first. */
54
+ lastRunAt: string | null;
55
+ /** Completion of the most recent successful run, or null. */
56
+ lastSuccessAt: string | null;
57
+ /** When the next attempt is due. */
58
+ nextDueAt: string;
59
+ durationMs: number | null;
60
+ counts: FindingsRunCounts | null;
61
+ /** Failure message (or lease holder) for the last attempt, else null. */
62
+ error: string | null;
63
+ }
64
+
65
+ /** Projection consumed by `gno daemon --status` and `gno doctor`. */
66
+ export interface FindingsRunStatus extends FindingsRunStateRecord {
67
+ state: FindingsRunState;
68
+ }
69
+
70
+ export interface FindingsSchedule {
71
+ collection: Collection;
72
+ cadence: string;
73
+ cadenceMs: number;
74
+ }
75
+
76
+ export type FindingsScheduleResolution =
77
+ | { ok: true; enabled: false }
78
+ | { ok: true; enabled: true; schedule: FindingsSchedule }
79
+ | { ok: false; error: string };
80
+
81
+ export const EMPTY_FINDINGS_COUNTS: FindingsRunCounts = {
82
+ findings: 0,
83
+ written: 0,
84
+ reopened: 0,
85
+ resolved: 0,
86
+ deleted: 0,
87
+ open: 0,
88
+ };
89
+
90
+ /**
91
+ * Validate the `findings` block against the loaded collections. Enabling
92
+ * without an existing collection is a startup error, never a silent no-op.
93
+ */
94
+ export function resolveFindingsSchedule(
95
+ config: Config
96
+ ): FindingsScheduleResolution {
97
+ const findings = config.findings;
98
+ if (!findings || !findings.enabled) return { ok: true, enabled: false };
99
+ if (!findings.collection) {
100
+ return {
101
+ ok: false,
102
+ error:
103
+ "findings.enabled is true but findings.collection is not set. Name an existing collection that should receive findings records, or set findings.enabled to false.",
104
+ };
105
+ }
106
+ const collection = config.collections.find(
107
+ (candidate) => candidate.name === findings.collection
108
+ );
109
+ if (!collection) {
110
+ return {
111
+ ok: false,
112
+ error: `findings.collection "${findings.collection}" is not a configured collection. Add it first (gno collection add <path> --name ${findings.collection}) or set findings.enabled to false; the daemon never creates collections.`,
113
+ };
114
+ }
115
+ const cadenceMs = parseFindingsCadenceMs(findings.cadence);
116
+ if (cadenceMs === null) {
117
+ return {
118
+ ok: false,
119
+ error: `findings.cadence "${findings.cadence}" is invalid: use <n>s|m|h|d between 10s and 30d (e.g. 6h).`,
120
+ };
121
+ }
122
+ return {
123
+ ok: true,
124
+ enabled: true,
125
+ schedule: { collection, cadence: findings.cadence, cadenceMs },
126
+ };
127
+ }
128
+
129
+ /** `<data>/<index-db-stem>.findings-run.json` */
130
+ export function findingsRunStatePath(dbPath: string): string {
131
+ const stem = basename(dbPath).replace(/\.sqlite$/, "");
132
+ return join(dirname(dbPath), `${stem}.findings-run.json`);
133
+ }
134
+
135
+ export function findingsRunStatePathForIndex(indexName?: string): string {
136
+ return findingsRunStatePath(getIndexDbPath(indexName));
137
+ }
138
+
139
+ export function createPendingFindingsRunState(
140
+ schedule: FindingsSchedule,
141
+ now: Date
142
+ ): FindingsRunStateRecord {
143
+ return {
144
+ schemaVersion: FINDINGS_RUN_STATE_SCHEMA_VERSION,
145
+ collection: schedule.collection.name,
146
+ cadence: schedule.cadence,
147
+ lastOutcome: "pending",
148
+ lastRunAt: null,
149
+ lastSuccessAt: null,
150
+ nextDueAt: new Date(now.getTime() + schedule.cadenceMs).toISOString(),
151
+ durationMs: null,
152
+ counts: null,
153
+ error: null,
154
+ };
155
+ }
156
+
157
+ export async function writeFindingsRunState(
158
+ path: string,
159
+ record: FindingsRunStateRecord
160
+ ): Promise<void> {
161
+ const temporaryDirectory = await mkdtemp(
162
+ join(dirname(path), `.${basename(path)}-`)
163
+ );
164
+ const temporaryPath = join(temporaryDirectory, "state");
165
+ try {
166
+ await Bun.write(temporaryPath, `${JSON.stringify(record, null, 2)}\n`, {
167
+ createPath: false,
168
+ mode: 0o600,
169
+ });
170
+ await chmod(temporaryPath, 0o600);
171
+ await rename(temporaryPath, path);
172
+ } finally {
173
+ await unlink(temporaryPath).catch(() => undefined);
174
+ await rmdir(temporaryDirectory).catch(() => undefined);
175
+ }
176
+ }
177
+
178
+ export async function deleteFindingsRunState(path: string): Promise<void> {
179
+ await unlink(path).catch(() => undefined);
180
+ }
181
+
182
+ const isOutcome = (value: unknown): value is FindingsRunOutcome =>
183
+ value === "pending" ||
184
+ value === "success" ||
185
+ value === "failed" ||
186
+ value === "skipped_lease";
187
+
188
+ const COUNT_KEYS = Object.keys(EMPTY_FINDINGS_COUNTS) as Array<
189
+ keyof FindingsRunCounts
190
+ >;
191
+
192
+ const isCount = (value: unknown): value is number =>
193
+ typeof value === "number" && Number.isFinite(value) && value >= 0;
194
+
195
+ /** Every count must be a finite non-negative number; anything else is corrupt. */
196
+ function asRunCounts(value: unknown): FindingsRunCounts | null {
197
+ if (typeof value !== "object" || value === null) return null;
198
+ const candidate = value as Record<string, unknown>;
199
+ const counts = { ...EMPTY_FINDINGS_COUNTS };
200
+ for (const key of COUNT_KEYS) {
201
+ const count = candidate[key];
202
+ if (!isCount(count)) return null;
203
+ counts[key] = count;
204
+ }
205
+ return counts;
206
+ }
207
+
208
+ function asRunStateRecord(value: unknown): FindingsRunStateRecord | null {
209
+ if (typeof value !== "object" || value === null) return null;
210
+ const candidate = value as Record<string, unknown>;
211
+ if (
212
+ candidate.schemaVersion !== FINDINGS_RUN_STATE_SCHEMA_VERSION ||
213
+ typeof candidate.collection !== "string" ||
214
+ typeof candidate.cadence !== "string" ||
215
+ !isOutcome(candidate.lastOutcome) ||
216
+ typeof candidate.nextDueAt !== "string"
217
+ ) {
218
+ return null;
219
+ }
220
+ const hasCounts = candidate.counts !== null && candidate.counts !== undefined;
221
+ const counts = hasCounts ? asRunCounts(candidate.counts) : null;
222
+ if (hasCounts && counts === null) return null;
223
+ return {
224
+ schemaVersion: FINDINGS_RUN_STATE_SCHEMA_VERSION,
225
+ collection: candidate.collection,
226
+ cadence: candidate.cadence,
227
+ lastOutcome: candidate.lastOutcome,
228
+ lastRunAt:
229
+ typeof candidate.lastRunAt === "string" ? candidate.lastRunAt : null,
230
+ lastSuccessAt:
231
+ typeof candidate.lastSuccessAt === "string"
232
+ ? candidate.lastSuccessAt
233
+ : null,
234
+ nextDueAt: candidate.nextDueAt,
235
+ durationMs:
236
+ typeof candidate.durationMs === "number" ? candidate.durationMs : null,
237
+ counts,
238
+ error: typeof candidate.error === "string" ? candidate.error : null,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Derive the reader-facing state. A run is `overdue` once the due time has
244
+ * slipped by a full cadence: the daemon is down, starved, or stuck.
245
+ */
246
+ export function projectFindingsRunStatus(
247
+ record: FindingsRunStateRecord,
248
+ now: Date = new Date()
249
+ ): FindingsRunStatus {
250
+ const cadenceMs = parseFindingsCadenceMs(record.cadence) ?? 0;
251
+ const dueAt = Date.parse(record.nextDueAt);
252
+ const overdue = Number.isFinite(dueAt) && now.getTime() > dueAt + cadenceMs;
253
+ return { ...record, state: overdue ? "overdue" : record.lastOutcome };
254
+ }
255
+
256
+ /** Read the persisted state; null when absent or unreadable. */
257
+ export async function readFindingsRunStatus(
258
+ path: string,
259
+ now: Date = new Date()
260
+ ): Promise<FindingsRunStatus | null> {
261
+ try {
262
+ const file = Bun.file(path);
263
+ if (!(await file.exists())) return null;
264
+ const record = asRunStateRecord(await file.json());
265
+ return record ? projectFindingsRunStatus(record, now) : null;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ export function formatFindingsRunStatusLine(status: FindingsRunStatus): string {
272
+ const parts: string[] = [status.state];
273
+ if (status.lastRunAt) parts.push(`last run ${status.lastRunAt}`);
274
+ if (status.counts) {
275
+ parts.push(
276
+ `${status.counts.open} open, ${status.counts.written} new, ${status.counts.resolved} resolved`
277
+ );
278
+ }
279
+ parts.push(`next due ${status.nextDueAt}`);
280
+ if (status.error) parts.push(`error: ${status.error}`);
281
+ return `${parts[0]} (${parts.slice(1).join("; ")})`;
282
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Persisted per-stage progress markers for staged indexing (fn-132 R4).
3
+ *
4
+ * `gno index` runs two separable stages - `lexical` (sync) and `embed` - and
5
+ * records each stage's lifecycle in `schema_meta` under one key. A stage left
6
+ * `running` by a process that died (SIGKILL, native crash, power loss) is
7
+ * reported by the next run's resume preamble as `interrupted`; the stage data
8
+ * itself (documents/chunks for lexical, per-batch vectors for embed) is already
9
+ * committed, so the next run continues from it without rework.
10
+ *
11
+ * @module src/embed/stage-state
12
+ */
13
+
14
+ import type { Database } from "bun:sqlite";
15
+
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // Types
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+
20
+ export const INDEX_STAGE_STATE_KEY = "index_stage_state";
21
+ export const INDEX_STAGE_STATE_VERSION = 1;
22
+
23
+ export type IndexStageName = "lexical" | "embed";
24
+
25
+ /** Terminal states a stage can report in a receipt. */
26
+ export type IndexStageState =
27
+ | "completed"
28
+ | "failed"
29
+ | "skipped"
30
+ | "interrupted";
31
+
32
+ /** Persisted lifecycle of one stage. */
33
+ export interface PersistedStageRecord {
34
+ state: "running" | "completed" | "failed";
35
+ /** Process that owned the stage (informational; the write lease guarantees exclusivity). */
36
+ pid: number;
37
+ startedAt: string;
38
+ finishedAt?: string;
39
+ /** Collection scope of the run that wrote the marker, when scoped. */
40
+ collection?: string;
41
+ }
42
+
43
+ export interface PersistedStageState {
44
+ version: number;
45
+ lexical?: PersistedStageRecord;
46
+ embed?: PersistedStageRecord;
47
+ }
48
+
49
+ /** Resume preamble payload: the stage a previous run left `running`. */
50
+ export interface InterruptedStage {
51
+ stage: IndexStageName;
52
+ state: "interrupted";
53
+ startedAt: string;
54
+ pid: number;
55
+ collection?: string;
56
+ }
57
+
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+ // Persistence
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+
62
+ function isStageRecord(value: unknown): value is PersistedStageRecord {
63
+ if (!value || typeof value !== "object") {
64
+ return false;
65
+ }
66
+ const record = value as Record<string, unknown>;
67
+ return (
68
+ (record.state === "running" ||
69
+ record.state === "completed" ||
70
+ record.state === "failed") &&
71
+ typeof record.pid === "number" &&
72
+ typeof record.startedAt === "string"
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Read the persisted stage state. A missing or malformed marker reads as
78
+ * empty - the marker is advisory resume metadata, never a gate.
79
+ */
80
+ export function readIndexStageState(db: Database): PersistedStageState {
81
+ const row = db
82
+ .prepare("SELECT value FROM schema_meta WHERE key = ?")
83
+ .get(INDEX_STAGE_STATE_KEY) as { value: string } | null;
84
+ if (!row) {
85
+ return { version: INDEX_STAGE_STATE_VERSION };
86
+ }
87
+ try {
88
+ const parsed = JSON.parse(row.value) as Record<string, unknown>;
89
+ return {
90
+ version: INDEX_STAGE_STATE_VERSION,
91
+ ...(isStageRecord(parsed.lexical) ? { lexical: parsed.lexical } : {}),
92
+ ...(isStageRecord(parsed.embed) ? { embed: parsed.embed } : {}),
93
+ };
94
+ } catch {
95
+ return { version: INDEX_STAGE_STATE_VERSION };
96
+ }
97
+ }
98
+
99
+ function writeIndexStageState(db: Database, state: PersistedStageState): void {
100
+ db.prepare(
101
+ `INSERT INTO schema_meta (key, value, updated_at)
102
+ VALUES (?, ?, datetime('now'))
103
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
104
+ ).run(INDEX_STAGE_STATE_KEY, JSON.stringify(state));
105
+ }
106
+
107
+ /**
108
+ * Mark a stage as running for this process. Overwrites any previous record
109
+ * for that stage (the caller has already surfaced an interrupted one).
110
+ */
111
+ export function markIndexStageRunning(
112
+ db: Database,
113
+ stage: IndexStageName,
114
+ options: { collection?: string; now?: () => Date } = {}
115
+ ): void {
116
+ const state = readIndexStageState(db);
117
+ state[stage] = {
118
+ state: "running",
119
+ pid: process.pid,
120
+ startedAt: (options.now ?? (() => new Date()))().toISOString(),
121
+ ...(options.collection ? { collection: options.collection } : {}),
122
+ };
123
+ writeIndexStageState(db, state);
124
+ }
125
+
126
+ /**
127
+ * Mark a running stage finished. A stage that was never marked running is
128
+ * recorded from scratch so the marker never claims a finish without a start.
129
+ */
130
+ export function markIndexStageFinished(
131
+ db: Database,
132
+ stage: IndexStageName,
133
+ outcome: "completed" | "failed",
134
+ options: { now?: () => Date } = {}
135
+ ): void {
136
+ const state = readIndexStageState(db);
137
+ const finishedAt = (options.now ?? (() => new Date()))().toISOString();
138
+ const previous = state[stage];
139
+ state[stage] = {
140
+ state: outcome,
141
+ pid: previous?.pid ?? process.pid,
142
+ startedAt: previous?.startedAt ?? finishedAt,
143
+ finishedAt,
144
+ ...(previous?.collection ? { collection: previous.collection } : {}),
145
+ };
146
+ writeIndexStageState(db, state);
147
+ }
148
+
149
+ /**
150
+ * Drop a stage's marker. Used when a run deliberately does not attempt a
151
+ * stage (`gno index --no-embed`) after surfacing a stale `running` marker for
152
+ * it, so later runs do not keep reporting an interruption that has already
153
+ * been acknowledged. Stage data is untouched: embed progress is persisted
154
+ * per batch and resumes from the data, not from this marker.
155
+ */
156
+ export function clearIndexStage(db: Database, stage: IndexStageName): void {
157
+ const state = readIndexStageState(db);
158
+ if (!state[stage]) {
159
+ return;
160
+ }
161
+ delete state[stage];
162
+ writeIndexStageState(db, state);
163
+ }
164
+
165
+ /**
166
+ * Detect the stage a previous run left `running`. Under the write lease only
167
+ * one writer runs at a time, so a `running` marker at run start always
168
+ * belongs to a process that died mid-stage. Later stages win when both are
169
+ * `running` (a stale lexical marker cannot survive a completed embed start).
170
+ */
171
+ export function findInterruptedStage(
172
+ state: PersistedStageState
173
+ ): InterruptedStage | null {
174
+ for (const stage of ["embed", "lexical"] as const) {
175
+ const record = state[stage];
176
+ if (record?.state === "running") {
177
+ return {
178
+ stage,
179
+ state: "interrupted",
180
+ startedAt: record.startedAt,
181
+ pid: record.pid,
182
+ ...(record.collection ? { collection: record.collection } : {}),
183
+ };
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ /** Human line for the resume preamble (stderr, non-JSON mode). */
190
+ export function formatInterruptedStage(interrupted: InterruptedStage): string {
191
+ const scope = interrupted.collection
192
+ ? ` (collection ${interrupted.collection})`
193
+ : "";
194
+ const continuation =
195
+ interrupted.stage === "embed"
196
+ ? "lexical index intact; embedding resumes from persisted progress without re-embedding completed chunks."
197
+ : "resuming lexical sync from persisted progress; unchanged files are skipped.";
198
+ return `Resuming: previous run (pid ${interrupted.pid}, started ${interrupted.startedAt}) was interrupted during the ${interrupted.stage} stage${scope}; ${continuation}`;
199
+ }