@log0u7/opencode-memory 0.3.0 → 0.5.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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Optional end-of-turn reflection: distills durable lessons from a session tail with
3
+ * one bounded LLM call and saves them as `lesson` entries. Best-effort by design:
4
+ * a disabled config wires nothing, a failing run is swallowed, and each session is
5
+ * reflected at most once.
6
+ */
7
+ const MAX_LESSONS_DEFAULT = 3;
8
+ const MAX_LESSONS_CAP = 10;
9
+ const TRANSCRIPT_CHARS_DEFAULT = 8000;
10
+ const TRANSCRIPT_CHARS_MIN = 500;
11
+ const TRANSCRIPT_CHARS_MAX = 50000;
12
+ const TIMEOUT_MS_DEFAULT = 60000;
13
+ const TIMEOUT_MS_MIN = 1000;
14
+ const TIMEOUT_MS_MAX = 600000;
15
+ /** Below this much transcript there is nothing to distill; saves the LLM call. */
16
+ const TRANSCRIPT_MIN_CHARS = 256;
17
+ const MAX_TRACKED = 512;
18
+ export function parseReflectionConfig(raw) {
19
+ if (raw === null || typeof raw !== "object" || raw.enabled !== true) {
20
+ return null;
21
+ }
22
+ const rawConfig = raw;
23
+ return {
24
+ enabled: true,
25
+ model: typeof rawConfig.model === "string" && rawConfig.model.includes("/")
26
+ ? rawConfig.model
27
+ : undefined,
28
+ maxLessons: clamp(rawConfig.maxLessons, 1, MAX_LESSONS_CAP, MAX_LESSONS_DEFAULT),
29
+ transcriptChars: clamp(rawConfig.transcriptChars, TRANSCRIPT_CHARS_MIN, TRANSCRIPT_CHARS_MAX, TRANSCRIPT_CHARS_DEFAULT),
30
+ timeoutMs: clamp(rawConfig.timeoutMs, TIMEOUT_MS_MIN, TIMEOUT_MS_MAX, TIMEOUT_MS_DEFAULT),
31
+ };
32
+ }
33
+ function clamp(value, min, max, fallback) {
34
+ if (typeof value !== "number" || !Number.isFinite(value)) {
35
+ return fallback;
36
+ }
37
+ return Math.min(max, Math.max(min, Math.round(value)));
38
+ }
39
+ export function distillPrompt(transcript, maxLessons) {
40
+ return (`You are the persistent-memory reflection for this project's OpenCode sessions. Below is the tail of one ` +
41
+ `coding session transcript. Extract at most ${maxLessons} durable, reusable lessons: non-obvious approaches ` +
42
+ `that clearly failed and why, non-obvious approaches that clearly worked and why, or constraints discovered ` +
43
+ `the hard way. Exclude anything trivially re-derivable from the code, anything session-specific, and any ` +
44
+ `credential. Reply with ONLY a JSON array, no markdown fences and no prose: ` +
45
+ `[{"title": "max 120 chars", "body": "the lesson, max 8000 chars"}]. Reply [] when nothing qualifies.\n\n` +
46
+ `SESSION TRANSCRIPT:\n${transcript}`);
47
+ }
48
+ export function parseLessons(text, max) {
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(text);
52
+ }
53
+ catch {
54
+ const fenced = text
55
+ .replace(/^```(?:json)?/m, "")
56
+ .replace(/```$/m, "")
57
+ .trim();
58
+ try {
59
+ parsed = JSON.parse(fenced);
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ if (!Array.isArray(parsed)) {
66
+ return [];
67
+ }
68
+ const lessons = [];
69
+ for (const item of parsed.slice(0, max)) {
70
+ if (item === null || typeof item !== "object") {
71
+ continue;
72
+ }
73
+ const { title, body } = item;
74
+ if (typeof title !== "string" || typeof body !== "string" || !title.trim() || !body.trim()) {
75
+ continue;
76
+ }
77
+ lessons.push({ title: title.slice(0, 120), body: body.slice(0, 8000) });
78
+ }
79
+ return lessons;
80
+ }
81
+ export function createReflection(config, deps) {
82
+ const done = new Set();
83
+ const inFlight = new Set();
84
+ const run = async (sessionID) => {
85
+ // Only a session we actually tried to reflect is marked done. A too-short
86
+ // transcript leaves it unsettled, so a later, longer idle can still reflect it
87
+ // once: idle fires at the end of every turn, not once per session.
88
+ let settled = false;
89
+ try {
90
+ if (await deps.isSubagent(sessionID)) {
91
+ settled = true;
92
+ return;
93
+ }
94
+ const tail = (await deps.transcript(sessionID)).slice(-config.transcriptChars);
95
+ if (tail.length < Math.min(TRANSCRIPT_MIN_CHARS, config.transcriptChars)) {
96
+ return;
97
+ }
98
+ settled = true;
99
+ // Bound the LLM call: a hung provider must not wedge a session forever. The
100
+ // hosts forward this signal to the SDK, so the abort also unblocks their own
101
+ // cleanup (the v1 host deletes the ephemeral child session in its `finally`).
102
+ const reply = await deps.generate(distillPrompt(tail, config.maxLessons), sessionID, AbortSignal.timeout(config.timeoutMs));
103
+ for (const lesson of parseLessons(reply, config.maxLessons)) {
104
+ deps.saveLesson(lesson.title, lesson.body);
105
+ }
106
+ }
107
+ catch {
108
+ // Reflection is best-effort: never surface a failure to the host. A failed
109
+ // attempt still settles the session so a broken pipeline is not retried forever.
110
+ settled = true;
111
+ }
112
+ finally {
113
+ inFlight.delete(sessionID);
114
+ if (settled) {
115
+ done.add(sessionID);
116
+ if (done.size > MAX_TRACKED) {
117
+ done.clear();
118
+ }
119
+ }
120
+ }
121
+ };
122
+ return (sessionID) => {
123
+ if (!sessionID || !config.enabled || done.has(sessionID) || inFlight.has(sessionID)) {
124
+ return;
125
+ }
126
+ inFlight.add(sessionID);
127
+ void run(sessionID);
128
+ };
129
+ }
130
+ //# sourceMappingURL=reflection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reflection.js","sourceRoot":"","sources":["../../src/lib/reflection.ts"],"names":[],"mappings":"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"}
@@ -5,5 +5,5 @@ export interface SqliteConn {
5
5
  exec(sql: string): void;
6
6
  close(): void;
7
7
  }
8
- export declare function openSqlite(path: string): SqliteConn;
8
+ export declare function openSqlite(path: string, load?: (id: string) => unknown): SqliteConn;
9
9
  //# sourceMappingURL=sqlite.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/lib/sqlite.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,CAAC;IACvD,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC;IAC5D,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC3C,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,IAAI,IAAI,CAAC;CACf;AAqED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAanD"}
1
+ {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/lib/sqlite.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,CAAC;IACvD,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC;IAC5D,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC3C,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,IAAI,IAAI,CAAC;CACf;AAsED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAqB,GAAG,UAAU,CAQhG"}
@@ -40,16 +40,19 @@ function createBunConn(db) {
40
40
  },
41
41
  };
42
42
  }
43
- // Open a sqlite connection with the runtime's bundled engine: bun:sqlite when
44
- // running inside OpenCode (Bun), node:sqlite when running under Node (tests, CI).
45
- export function openSqlite(path) {
46
- const globalRuntime = globalThis;
47
- if (globalRuntime.Bun) {
48
- return createBunConn(new globalRuntime.Bun.sqlite.Database(path));
43
+ // Open a sqlite connection with the runtime's bundled engine: bun:sqlite when running
44
+ // inside OpenCode (Bun), node:sqlite when running under Node (tests, CI).
45
+ // Bun 1.3 removed the Bun.sqlite global and ships the engine as the bun:sqlite module, so
46
+ // the branch reads the runtime version and the module goes through the same createRequire
47
+ // seam either way: the module exists in old and new Bun alike, and a bare require is not
48
+ // defined in the ES module the plugin ships as.
49
+ // The loader is a parameter so tests can supply the module on a runtime that lacks it.
50
+ export function openSqlite(path, load = nodeRequire) {
51
+ if (process.versions.bun !== undefined) {
52
+ const { Database } = load("bun:sqlite");
53
+ return createBunConn(new Database(path));
49
54
  }
50
- // node:sqlite ships with Node >= 22.5. createRequire keeps the load lazy for
51
- // runtimes without node:sqlite: a bare require is not defined in an ES module.
52
- const { DatabaseSync } = nodeRequire("node:sqlite");
55
+ const { DatabaseSync } = load("node:sqlite");
53
56
  return createPreparedConn(new DatabaseSync(path));
54
57
  }
55
58
  const nodeRequire = createRequire(import.meta.url);
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite.js","sourceRoot":"","sources":["../../src/lib/sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAgC5C,SAAS,kBAAkB,CAAC,EAAsB;IAChD,OAAO;QACL,GAAG,CAAc,GAAW,EAAE,MAAkB;YAC9C,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YAC7B,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAQ,CAAC;QAC5C,CAAC;QACD,GAAG,CAAc,GAAW,EAAE,MAAkB;YAC9C,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YAC7B,OAAQ,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAmB,IAAI,IAAI,CAAC;QAChE,CAAC;QACD,GAAG,CAAC,GAAW,EAAE,MAAkB;YACjC,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YAC7B,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC;QAC9B,CAAC;QACD,IAAI,CAAC,GAAW;YACd,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACf,CAAC;QACD,KAAK;YACH,EAAE,CAAC,KAAK,EAAE,CAAC;QACb,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,aAAa,CAAC,EAAqB;IAC1C,OAAO;QACL,GAAG,CAAc,GAAW,EAAE,MAAkB;YAC9C,OAAO,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAQ,CAAC;QACrD,CAAC;QACD,GAAG,CAAc,GAAW,EAAE,MAAkB;YAC9C,OAAQ,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAmB,IAAI,IAAI,CAAC;QACzE,CAAC;QACD,GAAG,CAAC,GAAW,EAAE,MAAkB;YACjC,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC;QAC5B,CAAC;QACD,IAAI,CAAC,GAAW;YACd,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACd,CAAC;QACD,KAAK;YACH,EAAE,CAAC,KAAK,EAAE,CAAC;QACb,CAAC;KACF,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,kFAAkF;AAClF,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,MAAM,aAAa,GAAG,UAErB,CAAC;IAEF,IAAI,aAAa,CAAC,GAAG,EAAE,CAAC;QACtB,OAAO,aAAa,CAAC,IAAI,aAAa,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;IACpE,CAAC;IAED,6EAA6E;IAC7E,+EAA+E;IAC/E,MAAM,EAAE,YAAY,EAAE,GAAG,WAAW,CAAC,aAAa,CAAsB,CAAC;IACzE,OAAO,kBAAkB,CAAC,IAAI,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC;AACpD,CAAC;AAED,MAAM,WAAW,GAAG,aAAa,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC"}
1
+ {"version":3,"file":"sqlite.js","sourceRoot":"","sources":["../../src/lib/sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AA4B5C,SAAS,kBAAkB,CAAC,EAAsB;IAChD,OAAO;QACL,GAAG,CAAc,GAAW,EAAE,MAAkB;YAC9C,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YAC7B,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAQ,CAAC;QAC5C,CAAC;QACD,GAAG,CAAc,GAAW,EAAE,MAAkB;YAC9C,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YAC7B,OAAQ,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAmB,IAAI,IAAI,CAAC;QAChE,CAAC;QACD,GAAG,CAAC,GAAW,EAAE,MAAkB;YACjC,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YAC7B,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC;QAC9B,CAAC;QACD,IAAI,CAAC,GAAW;YACd,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACf,CAAC;QACD,KAAK;YACH,EAAE,CAAC,KAAK,EAAE,CAAC;QACb,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,aAAa,CAAC,EAAqB;IAC1C,OAAO;QACL,GAAG,CAAc,GAAW,EAAE,MAAkB;YAC9C,OAAO,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAQ,CAAC;QACrD,CAAC;QACD,GAAG,CAAc,GAAW,EAAE,MAAkB;YAC9C,OAAQ,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAmB,IAAI,IAAI,CAAC;QACzE,CAAC;QACD,GAAG,CAAC,GAAW,EAAE,MAAkB;YACjC,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC;QAC5B,CAAC;QACD,IAAI,CAAC,GAAW;YACd,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACd,CAAC;QACD,KAAK;YACH,EAAE,CAAC,KAAK,EAAE,CAAC;QACb,CAAC;KACF,CAAC;AACJ,CAAC;AAED,sFAAsF;AACtF,0EAA0E;AAC1E,0FAA0F;AAC1F,0FAA0F;AAC1F,yFAAyF;AACzF,gDAAgD;AAChD,uFAAuF;AACvF,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,IAAI,GAA4B,WAAW;IAClF,IAAI,OAAO,CAAC,QAAQ,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;QACvC,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC,YAAY,CAAqB,CAAC;QAC5D,OAAO,aAAa,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;IAC3C,CAAC;IAED,MAAM,EAAE,YAAY,EAAE,GAAG,IAAI,CAAC,aAAa,CAAsB,CAAC;IAClE,OAAO,kBAAkB,CAAC,IAAI,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC;AACpD,CAAC;AAED,MAAM,WAAW,GAAG,aAAa,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC"}
@@ -12,6 +12,9 @@ export declare const DEFAULT_LIMIT: {
12
12
  };
13
13
  /** Scope used when no worktree and no directory is known, so rows stay addressable. */
14
14
  export declare const FALLBACK_SCOPE = "/";
15
+ /** How long a trashed entry stays restorable before a sweep drops it for good. A
16
+ * recoverable delete is worthless if the trash itself grows without bound. */
17
+ export declare const TRASH_RETENTION_MS: number;
15
18
  export declare class SecretDetectedError extends Error {
16
19
  constructor();
17
20
  }
@@ -33,9 +36,27 @@ export declare function renderEntries(entries: Entry[]): string;
33
36
  export declare function sweep(db: MemoryDb, options?: {
34
37
  now?: number;
35
38
  }): number;
39
+ /** Whether a save inserted a row or rewrote the one already holding that scope and
40
+ * title, so a caller can tell the model what actually happened. */
41
+ export type SaveResult = {
42
+ entry: Entry;
43
+ created: boolean;
44
+ };
36
45
  export declare function save(db: MemoryDb, input: EntryInput, options?: {
37
46
  now?: number;
38
47
  }): Entry;
48
+ export declare function saveEntry(db: MemoryDb, input: EntryInput, options?: {
49
+ now?: number;
50
+ }): SaveResult;
51
+ /**
52
+ * Fused search. The strict pass runs first and the prefix pass only fills what is left of
53
+ * the limit, so a query never loses the loose matches it used to discard: two entries
54
+ * holding every token answer a limit of ten with two results today.
55
+ *
56
+ * A strict hit contains every token by construction, so it is definitionally stronger
57
+ * than a prefix hit. Pinning strict above prefix is therefore the correct special case of
58
+ * reciprocal rank fusion, without the score arithmetic.
59
+ */
39
60
  export declare function search(db: MemoryDb, query: {
40
61
  query: string;
41
62
  scope: string;
@@ -46,6 +67,7 @@ export declare function list(db: MemoryDb, query: {
46
67
  scope: string;
47
68
  limit?: number;
48
69
  now?: number;
70
+ kind?: string;
49
71
  }): Entry[];
50
72
  export type ForgetTarget = {
51
73
  scope: string;
@@ -54,10 +76,35 @@ export type ForgetTarget = {
54
76
  } | {
55
77
  query: string;
56
78
  });
79
+ /** Counts by kind for one scope plus `global`, expired entries excluded. */
80
+ export declare function stats(db: MemoryDb, query: {
81
+ scope: string;
82
+ now?: number;
83
+ }): Array<{
84
+ kind: string;
85
+ count: number;
86
+ }>;
87
+ /** Creation date of the oldest live entry in one scope plus `global`, or null when the
88
+ * scope is empty: the store grows without bound, so age is the signal that knowledge is
89
+ * rotting rather than accumulating. */
90
+ export declare function oldestCreated(db: MemoryDb, query: {
91
+ scope: string;
92
+ now?: number;
93
+ }): number | null;
57
94
  /**
58
- * Deletes inside one scope (plus the shared `global` scope), never across the whole
59
- * machine: the store is shared, so an unscoped delete would silently hit other
60
- * projects.
95
+ * Query deletes stay inside one scope (plus the shared `global` scope): a fuzzy match
96
+ * must never silently hit other projects' entries. Id deletes are machine-wide because
97
+ * the id is globally unique: closed worktrees stay cleanable from any project.
98
+ *
99
+ * Both forms trash instead of dropping, so a wrong delete is recoverable, and both report
100
+ * the entries they moved: a broad query can take many rows at once, and the caller has to
101
+ * be able to say which ones. Only live rows count, so a second delete of the same target
102
+ * is a no-op.
61
103
  */
62
- export declare function forget(db: MemoryDb, target: ForgetTarget): number;
104
+ export declare function forget(db: MemoryDb, target: ForgetTarget): Entry[];
105
+ /** Puts a trashed entry back into every read. Machine-wide by id, mirroring the delete it
106
+ * undoes; `null` when the id is not in the trash. */
107
+ export declare function restore(db: MemoryDb, target: {
108
+ id: string;
109
+ }): Entry | null;
63
110
  //# sourceMappingURL=store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/lib/store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACxC,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAK3D,eAAO,MAAM,OAAO,KAAK,CAAC;AAC1B,eAAO,MAAM,QAAQ,KAAK,CAAC;AAC3B,sFAAsF;AACtF,eAAO,MAAM,SAAS,KAAK,CAAC;AAC5B,kFAAkF;AAClF,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,eAAO,MAAM,aAAa;IAAK,MAAM;IAAM,IAAI;CAAM,CAAC;AAEtD,uFAAuF;AACvF,eAAO,MAAM,cAAc,MAAM,CAAC;AAQlC,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,cAGC;CACF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,UAAU,EAAE,KAAK,CAAC,MAAM,GAAG,SAAS,CAAC,GAAG,MAAM,CAM/E;AA+BD;;yBAEyB;AACzB,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKlF;AASD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,KAAK,EAAE,GAAG,MAAM,CAEtD;AAED,wBAAgB,KAAK,CAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,MAAM,CAI1E;AAED,wBAAgB,IAAI,CAAC,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,UAAU,EAAE,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,KAAK,CAuE3F;AAED,wBAAgB,MAAM,CACpB,EAAE,EAAE,QAAQ,EACZ,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GACpE,KAAK,EAAE,CAqBT;AAED,wBAAgB,IAAI,CAClB,EAAE,EAAE,QAAQ,EACZ,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GACrD,KAAK,EAAE,CAcT;AAED,MAAM,MAAM,YAAY,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,CAAC;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEpF;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,GAAG,MAAM,CAwBjE"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/lib/store.ts"],"names":[],"mappings":"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"}
package/dist/lib/store.js CHANGED
@@ -11,10 +11,32 @@ export const BODY_PREVIEW_MAX = 500;
11
11
  export const DEFAULT_LIMIT = { search: 10, list: 20 };
12
12
  /** Scope used when no worktree and no directory is known, so rows stay addressable. */
13
13
  export const FALLBACK_SCOPE = "/";
14
+ /** How long a trashed entry stays restorable before a sweep drops it for good. A
15
+ * recoverable delete is worthless if the trash itself grows without bound. */
16
+ export const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
17
+ /** Live rows only: a trashed entry is invisible to every read until it is restored. */
18
+ const LIVE = "deleted_at IS NULL";
19
+ // High-confidence credential formats only: a generic "password =" rule would block
20
+ // ordinary prose. Extend with care, one false positive costs a legitimate save.
21
+ // The three patterns whose tail class allows `_` or `-` (github_pat_, gl(pat|rt)- and
22
+ // ya29.) demand a digit or an uppercase letter in the tail: without it, a sentence written
23
+ // as one long token after the prefix is refused as a credential. The older sk-, AIza and
24
+ // xox[ap]- patterns keep their original shape and carry the same false-positive class;
25
+ // left as they are to keep this change focused.
14
26
  const SECRET_PATTERNS = [
15
- /\bsk-[A-Za-z0-9_-]{20,}\b/,
16
- /\bghp_[A-Za-z0-9]{30,}\b/,
17
- /\bxoxb-\d{10,}-\d{10,}\b/,
27
+ /\bsk-[A-Za-z0-9_-]{20,}\b/, // OpenAI-style, also covers Anthropic sk-ant- keys
28
+ /\bgh[posur]_[A-Za-z0-9]{30,}\b/, // GitHub PAT, OAuth, server-to-server, user-to-server, refresh
29
+ /\bgithub_pat_(?=[A-Za-z0-9_]*[0-9A-Z])[A-Za-z0-9_]{22,}\b/, // GitHub fine-grained PAT
30
+ /\bxoxb-\d{10,}-\d{10,}\b/, // Slack bot token
31
+ /\bxox[ap]-[0-9A-Za-z-]{10,}/, // Slack user / app token
32
+ /https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9]+/, // Slack incoming webhook
33
+ /\bA(?:K|S)IA[0-9A-Z]{16}\b/, // AWS access key id, long-lived and temporary
34
+ /\bgl(?:pat|rt)-(?=[0-9A-Za-z_-]*[0-9A-Z])[0-9A-Za-z_-]{20,}\b/, // GitLab PAT and rotate token
35
+ /\bnpm_[A-Za-z0-9]{36}\b/, // npm granular token
36
+ /\bAIza[0-9A-Za-z_-]{35}/, // Google API key
37
+ /\bya29\.(?=[0-9A-Za-z_-]*[0-9A-Z])[0-9A-Za-z_-]{20,}\b/, // Google OAuth access token
38
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, // JWT
39
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM private key block header
18
40
  ];
19
41
  export class SecretDetectedError extends Error {
20
42
  constructor() {
@@ -86,9 +108,19 @@ export function renderEntries(entries) {
86
108
  export function sweep(db, options = {}) {
87
109
  const now = options.now ?? Date.now();
88
110
  db.conn.run("DELETE FROM entries WHERE expires_at IS NOT NULL AND expires_at <= ?", [now]);
111
+ const expired = changesOf(db);
112
+ db.conn.run("DELETE FROM entries WHERE deleted_at IS NOT NULL AND deleted_at <= ?", [
113
+ now - TRASH_RETENTION_MS,
114
+ ]);
115
+ return expired + changesOf(db);
116
+ }
117
+ function changesOf(db) {
89
118
  return db.conn.get("SELECT changes() AS changes")?.changes ?? 0;
90
119
  }
91
120
  export function save(db, input, options = {}) {
121
+ return saveEntry(db, input, options).entry;
122
+ }
123
+ export function saveEntry(db, input, options = {}) {
92
124
  validateEntry(input);
93
125
  const scope = canonicalScope(input.scope);
94
126
  const now = options.now ?? Date.now();
@@ -100,30 +132,35 @@ export function save(db, input, options = {}) {
100
132
  // different case is not deduped; and the read-then-write is not transactional, so two
101
133
  // concurrent saves of the same title can both insert. Ceiling is a rare duplicate, not
102
134
  // data loss. Upgrade path: a normalized key column plus BEGIN IMMEDIATE.
103
- const existing = db.conn.get("SELECT id, created_at FROM entries WHERE scope = ? AND lower(trim(title)) = ?", [scope, input.title.trim().toLowerCase()]);
135
+ const existing = db.conn.get(`SELECT id, created_at FROM entries WHERE scope = ? AND lower(trim(title)) = ? AND ${LIVE}`, [scope, input.title.trim().toLowerCase()]);
104
136
  if (existing) {
105
- db.conn.run("UPDATE entries SET kind = ?, body = ?, tags = ?, updated_at = ?, expires_at = ? WHERE id = ?", [
137
+ db.conn.run("UPDATE entries SET kind = ?, body = ?, tags = ?, updated_at = ?, expires_at = ?, source = ? WHERE id = ?", [
106
138
  input.kind,
107
139
  input.body,
108
140
  JSON.stringify(input.tags),
109
141
  now,
110
142
  input.expires_at ?? null,
143
+ input.source ?? null,
111
144
  existing.id,
112
145
  ]);
113
146
  return {
114
- id: existing.id,
115
- scope,
116
- kind: input.kind,
117
- title: input.title,
118
- body: input.body,
119
- tags: input.tags,
120
- created_at: existing.created_at,
121
- updated_at: now,
122
- expires_at: input.expires_at,
147
+ entry: {
148
+ id: existing.id,
149
+ scope,
150
+ kind: input.kind,
151
+ title: input.title,
152
+ body: input.body,
153
+ tags: input.tags,
154
+ created_at: existing.created_at,
155
+ updated_at: now,
156
+ expires_at: input.expires_at,
157
+ ...(input.source !== undefined ? { source: input.source } : {}),
158
+ },
159
+ created: false,
123
160
  };
124
161
  }
125
162
  const id = randomUUID();
126
- db.conn.run("INSERT INTO entries (id, scope, kind, title, body, tags, created_at, updated_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [
163
+ db.conn.run("INSERT INTO entries (id, scope, kind, title, body, tags, created_at, updated_at, expires_at, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
127
164
  id,
128
165
  scope,
129
166
  input.kind,
@@ -133,75 +170,141 @@ export function save(db, input, options = {}) {
133
170
  now,
134
171
  now,
135
172
  input.expires_at,
173
+ input.source ?? null,
136
174
  ]);
137
175
  return {
138
- id,
139
- scope,
140
- kind: input.kind,
141
- title: input.title,
142
- body: input.body,
143
- tags: input.tags,
144
- created_at: now,
145
- updated_at: now,
146
- expires_at: input.expires_at,
176
+ entry: {
177
+ id,
178
+ scope,
179
+ kind: input.kind,
180
+ title: input.title,
181
+ body: input.body,
182
+ tags: input.tags,
183
+ created_at: now,
184
+ updated_at: now,
185
+ expires_at: input.expires_at,
186
+ ...(input.source !== undefined ? { source: input.source } : {}),
187
+ },
188
+ created: true,
147
189
  };
148
190
  }
191
+ /**
192
+ * Fused search. The strict pass runs first and the prefix pass only fills what is left of
193
+ * the limit, so a query never loses the loose matches it used to discard: two entries
194
+ * holding every token answer a limit of ten with two results today.
195
+ *
196
+ * A strict hit contains every token by construction, so it is definitionally stronger
197
+ * than a prefix hit. Pinning strict above prefix is therefore the correct special case of
198
+ * reciprocal rank fusion, without the score arithmetic.
199
+ */
149
200
  export function search(db, query) {
150
201
  const scope = canonicalScope(query.scope);
151
202
  const limit = clampLimit(query.limit, DEFAULT_LIMIT.search);
152
203
  const now = query.now ?? Date.now();
204
+ const hits = [];
205
+ const seen = new Set();
153
206
  for (const match of searchCandidates(query.query)) {
154
- const hits = db.conn
155
- .all(`SELECT e.* FROM entries_fts JOIN entries e ON e.rowid = entries_fts.rowid
156
- WHERE entries_fts MATCH ? AND (e.scope = ? OR e.scope = 'global')
157
- AND (e.expires_at IS NULL OR e.expires_at > ?)
158
- ORDER BY bm25(entries_fts) ASC, e.updated_at DESC
159
- LIMIT ?`, [match, scope, now, limit])
160
- .map(rowToEntry);
161
- if (hits.length > 0) {
162
- return hits;
207
+ for (const entry of ftsHits(db, match, scope, now, limit)) {
208
+ if (!seen.has(entry.id)) {
209
+ seen.add(entry.id);
210
+ hits.push(entry);
211
+ }
212
+ }
213
+ if (hits.length >= limit) {
214
+ break;
163
215
  }
164
216
  }
165
- return [];
217
+ return hits.slice(0, limit);
218
+ }
219
+ function ftsHits(db, match, scope, now, limit) {
220
+ return db.conn
221
+ .all(`SELECT e.* FROM entries_fts JOIN entries e ON e.rowid = entries_fts.rowid
222
+ WHERE entries_fts MATCH ? AND (e.scope = ? OR e.scope = 'global')
223
+ AND (e.expires_at IS NULL OR e.expires_at > ?) AND e.${LIVE}
224
+ ORDER BY bm25(entries_fts) ASC, e.updated_at DESC
225
+ LIMIT ?`, [match, scope, now, limit])
226
+ .map(rowToEntry);
166
227
  }
167
228
  export function list(db, query) {
168
229
  const scope = canonicalScope(query.scope);
169
230
  const limit = clampLimit(query.limit, DEFAULT_LIMIT.list);
170
231
  const now = query.now ?? Date.now();
232
+ const kindFilter = query.kind === undefined ? "" : "AND kind = ? ";
233
+ const kindArgs = query.kind === undefined ? [] : [query.kind];
171
234
  return db.conn
172
235
  .all(`SELECT * FROM entries
173
- WHERE (scope = ? OR scope = 'global') AND (expires_at IS NULL OR expires_at > ?)
236
+ WHERE (scope = ? OR scope = 'global') AND (expires_at IS NULL OR expires_at > ?) ${kindFilter}
237
+ AND ${LIVE}
174
238
  ORDER BY updated_at DESC, id ASC
175
- LIMIT ?`, [scope, now, limit])
239
+ LIMIT ?`, [scope, now, ...kindArgs, limit])
176
240
  .map(rowToEntry);
177
241
  }
242
+ /** Counts by kind for one scope plus `global`, expired entries excluded. */
243
+ export function stats(db, query) {
244
+ const scope = canonicalScope(query.scope);
245
+ const now = query.now ?? Date.now();
246
+ return db.conn.all(`SELECT kind, COUNT(*) AS count FROM entries
247
+ WHERE (scope = ? OR scope = 'global') AND (expires_at IS NULL OR expires_at > ?) AND ${LIVE}
248
+ GROUP BY kind ORDER BY count DESC, kind ASC`, [scope, now]);
249
+ }
250
+ /** Creation date of the oldest live entry in one scope plus `global`, or null when the
251
+ * scope is empty: the store grows without bound, so age is the signal that knowledge is
252
+ * rotting rather than accumulating. */
253
+ export function oldestCreated(db, query) {
254
+ const scope = canonicalScope(query.scope);
255
+ const now = query.now ?? Date.now();
256
+ const row = db.conn.get(`SELECT MIN(created_at) AS oldest FROM entries
257
+ WHERE (scope = ? OR scope = 'global') AND (expires_at IS NULL OR expires_at > ?) AND ${LIVE}`, [scope, now]);
258
+ return row?.oldest ?? null;
259
+ }
178
260
  /**
179
- * Deletes inside one scope (plus the shared `global` scope), never across the whole
180
- * machine: the store is shared, so an unscoped delete would silently hit other
181
- * projects.
261
+ * Query deletes stay inside one scope (plus the shared `global` scope): a fuzzy match
262
+ * must never silently hit other projects' entries. Id deletes are machine-wide because
263
+ * the id is globally unique: closed worktrees stay cleanable from any project.
264
+ *
265
+ * Both forms trash instead of dropping, so a wrong delete is recoverable, and both report
266
+ * the entries they moved: a broad query can take many rows at once, and the caller has to
267
+ * be able to say which ones. Only live rows count, so a second delete of the same target
268
+ * is a no-op.
182
269
  */
183
270
  export function forget(db, target) {
184
- const scope = canonicalScope(target.scope);
185
- if ("id" in target) {
186
- db.conn.run("DELETE FROM entries WHERE id = ? AND (scope = ? OR scope = 'global')", [
187
- target.id,
188
- scope,
189
- ]);
190
- return changesOf(db);
271
+ const trashed = "id" in target
272
+ ? db.conn.all(`SELECT * FROM entries WHERE id = ? AND ${LIVE}`, [target.id])
273
+ : trashedByQuery(db, target);
274
+ if (trashed.length === 0) {
275
+ return [];
191
276
  }
277
+ const now = Date.now();
278
+ const ids = trashed.map((row) => row.id);
279
+ db.conn.run(`UPDATE entries SET deleted_at = ? WHERE id IN (${ids.map(() => "?").join(", ")})`, [
280
+ now,
281
+ ...ids,
282
+ ]);
283
+ return trashed.map(rowToEntry);
284
+ }
285
+ /** Puts a trashed entry back into every read. Machine-wide by id, mirroring the delete it
286
+ * undoes; `null` when the id is not in the trash. */
287
+ export function restore(db, target) {
288
+ const row = db.conn.get(`SELECT * FROM entries WHERE id = ? AND deleted_at IS NOT NULL`, [
289
+ target.id,
290
+ ]);
291
+ if (row === null) {
292
+ return null;
293
+ }
294
+ db.conn.run("UPDATE entries SET deleted_at = NULL WHERE id = ?", [target.id]);
295
+ return rowToEntry(row);
296
+ }
297
+ function trashedByQuery(db, target) {
298
+ const scope = canonicalScope(target.scope);
192
299
  // Deleting is precision-first: only the strict all-token match, never the fuzzy
193
- // fallback. A wrong read costs the model a retry, a wrong delete is unrecoverable.
300
+ // fallback. A wrong read costs the model a retry, a wrong delete used to be
301
+ // unrecoverable; it now costs a restore.
194
302
  const match = strictMatch(target.query);
195
303
  if (match === null) {
196
- return 0;
304
+ return [];
197
305
  }
198
- db.conn.run(`DELETE FROM entries WHERE id IN (
199
- SELECT e.id FROM entries_fts JOIN entries e ON e.rowid = entries_fts.rowid WHERE entries_fts MATCH ?
200
- ) AND (scope = ? OR scope = 'global')`, [match, scope]);
201
- return changesOf(db);
202
- }
203
- function changesOf(db) {
204
- return db.conn.get("SELECT changes() AS changes")?.changes ?? 0;
306
+ return db.conn.all(`SELECT e.* FROM entries_fts JOIN entries e ON e.rowid = entries_fts.rowid
307
+ WHERE entries_fts MATCH ? AND (e.scope = ? OR e.scope = 'global') AND e.${LIVE}`, [match, scope]);
205
308
  }
206
309
  function rowToEntry(row) {
207
310
  return {
@@ -214,6 +317,7 @@ function rowToEntry(row) {
214
317
  created_at: row.created_at,
215
318
  updated_at: row.updated_at,
216
319
  expires_at: row.expires_at,
320
+ ...(row.source !== null ? { source: row.source } : {}),
217
321
  };
218
322
  }
219
323
  function parseTags(raw) {
@@ -227,8 +331,8 @@ function parseTags(raw) {
227
331
  }
228
332
  /**
229
333
  * FTS5 candidates, most precise first, each quoted so arbitrary model input stays a
230
- * literal search. The strict AND match is tried before the looser prefix OR match:
231
- * recall matters more than precision once the strict match found nothing.
334
+ * literal search. The strict AND match is fused ahead of the looser prefix OR match:
335
+ * precision leads, recall fills what is left of the limit.
232
336
  */
233
337
  function searchCandidates(raw) {
234
338
  const strict = strictMatch(raw);