@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.
- package/CHANGELOG.md +105 -0
- package/README.md +32 -8
- package/dist/lib/db.d.ts +3 -0
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +50 -3
- package/dist/lib/db.js.map +1 -1
- package/dist/lib/memory-ops.d.ts +15 -6
- package/dist/lib/memory-ops.d.ts.map +1 -1
- package/dist/lib/memory-ops.js +66 -20
- package/dist/lib/memory-ops.js.map +1 -1
- package/dist/lib/reflection.d.ts +27 -0
- package/dist/lib/reflection.d.ts.map +1 -0
- package/dist/lib/reflection.js +130 -0
- package/dist/lib/reflection.js.map +1 -0
- package/dist/lib/sqlite.d.ts +1 -1
- package/dist/lib/sqlite.d.ts.map +1 -1
- package/dist/lib/sqlite.js +12 -9
- package/dist/lib/sqlite.js.map +1 -1
- package/dist/lib/store.d.ts +51 -4
- package/dist/lib/store.d.ts.map +1 -1
- package/dist/lib/store.js +161 -57
- package/dist/lib/store.js.map +1 -1
- package/dist/plugin-v2.d.ts.map +1 -1
- package/dist/plugin-v2.js +131 -5
- package/dist/plugin-v2.js.map +1 -1
- package/dist/plugin.d.ts +3 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +119 -4
- package/dist/plugin.js.map +1 -1
- package/dist/types/entry.d.ts +4 -0
- package/dist/types/entry.d.ts.map +1 -1
- package/package.json +5 -5
|
@@ -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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"}
|
package/dist/lib/sqlite.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/sqlite.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/lib/sqlite.js
CHANGED
|
@@ -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
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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);
|
package/dist/lib/sqlite.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite.js","sourceRoot":"","sources":["../../src/lib/sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,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"}
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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):
|
|
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
|
package/dist/lib/store.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
/\
|
|
17
|
-
/\
|
|
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(
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
|
304
|
+
return [];
|
|
197
305
|
}
|
|
198
|
-
db.conn.
|
|
199
|
-
|
|
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
|
|
231
|
-
* recall
|
|
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);
|