@jmtrin/kevin-core 1.5.0 → 2.1.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/README.md +711 -667
- package/dist/RepoTruth.js +6 -1
- package/dist/Retrospective.d.ts +1 -1
- package/dist/Retrospective.js +6 -0
- package/dist/contract.d.ts +58 -1
- package/dist/contract.js +216 -4
- package/dist/import-host.d.ts +1 -1
- package/dist/import-host.js +33 -16
- package/dist/index.d.ts +14 -10
- package/dist/index.js +15 -6
- package/dist/metrics.d.ts +1 -1
- package/dist/metrics.js +20 -0
- package/dist/migrations/014_v2_commonwealth.sql +50 -0
- package/dist/migrations/015_v21_relay.sql +20 -0
- package/dist/okf-shards.d.ts +10 -0
- package/dist/okf-shards.js +108 -0
- package/dist/okf.d.ts +5 -1
- package/dist/okf.js +10 -3
- package/dist/sources/ClaudeMemorySource.d.ts +10 -0
- package/dist/sources/ClaudeMemorySource.js +62 -0
- package/dist/sources/CodexMemoriesSource.d.ts +10 -0
- package/dist/sources/CodexMemoriesSource.js +48 -0
- package/dist/sources/IdleSync.d.ts +13 -0
- package/dist/sources/IdleSync.js +197 -0
- package/dist/sources/MemorySource.d.ts +19 -0
- package/dist/sources/MemorySource.js +1 -0
- package/dist/sources/OpencodeNativeSource.d.ts +15 -0
- package/dist/sources/OpencodeNativeSource.js +135 -0
- package/dist/sources/OpencodePluginSource.d.ts +9 -0
- package/dist/sources/OpencodePluginSource.js +15 -0
- package/dist/sources/deletion.d.ts +18 -0
- package/dist/sources/deletion.js +54 -0
- package/package.json +24 -26
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- ============================================================
|
|
2
|
+
-- Kevin v2.0.0 "Commonwealth" — MemorySources, OKF v3, retirements
|
|
3
|
+
-- Migration 014. Forward-only. Additive + translation + cleanup.
|
|
4
|
+
-- ============================================================
|
|
5
|
+
|
|
6
|
+
-- 1. MemorySources table (K16-012 / plan §4.4)
|
|
7
|
+
CREATE TABLE IF NOT EXISTS memory_sources (
|
|
8
|
+
name TEXT PRIMARY KEY,
|
|
9
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
10
|
+
precedence INTEGER NOT NULL,
|
|
11
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
INSERT OR IGNORE INTO memory_sources (name, enabled, precedence) VALUES
|
|
15
|
+
('opencode-plugin', 1, 10),
|
|
16
|
+
('claude-memory', 0, 20),
|
|
17
|
+
('codex-memories', 0, 30),
|
|
18
|
+
('opencode-native', 0, 40);
|
|
19
|
+
|
|
20
|
+
-- 2. Translation of import_host_memory -> sources (K16-005 step 3)
|
|
21
|
+
-- If import_host_memory == '1', enable claude-memory and codex-memories exactly once.
|
|
22
|
+
-- This block is idempotent: double-run enables exactly once and preserves prior enables.
|
|
23
|
+
UPDATE memory_sources SET enabled = 1 WHERE name IN ('claude-memory','codex-memories')
|
|
24
|
+
AND EXISTS (SELECT 1 FROM kevin_settings WHERE key='import_host_memory' AND value='1');
|
|
25
|
+
|
|
26
|
+
-- 3. New settings seeds (K16-013 / plan §4.4 + K16-008 okf_write_version)
|
|
27
|
+
INSERT OR IGNORE INTO kevin_settings (key, value) VALUES
|
|
28
|
+
('sources_enabled', '1'),
|
|
29
|
+
('source_claude_memory', '0'),
|
|
30
|
+
('source_codex_memories', '0'),
|
|
31
|
+
('source_opencode_native', '0'),
|
|
32
|
+
('okf_write_version', '3');
|
|
33
|
+
|
|
34
|
+
-- Sync memory_sources enabled from individual source_* flags if they exist (absorption)
|
|
35
|
+
-- source_claude_memory / source_codex_memories are TEXT "1"/"0"
|
|
36
|
+
UPDATE memory_sources SET enabled = 1 WHERE name='claude-memory' AND EXISTS (SELECT 1 FROM kevin_settings WHERE key='source_claude_memory' AND value='1');
|
|
37
|
+
UPDATE memory_sources SET enabled = 1 WHERE name='codex-memories' AND EXISTS (SELECT 1 FROM kevin_settings WHERE key='source_codex_memories' AND value='1');
|
|
38
|
+
UPDATE memory_sources SET enabled = 1 WHERE name='opencode-native' AND EXISTS (SELECT 1 FROM kevin_settings WHERE key='source_opencode_native' AND value='1');
|
|
39
|
+
|
|
40
|
+
-- 4. Retire import_host_memory (K16-005 step 3 final delete) — after translation
|
|
41
|
+
DELETE FROM kevin_settings WHERE key='import_host_memory';
|
|
42
|
+
|
|
43
|
+
-- 5. New metrics seeds (K16-012)
|
|
44
|
+
INSERT OR IGNORE INTO kevin_metrics (key, value) VALUES
|
|
45
|
+
('source_syncs_total', 0),
|
|
46
|
+
('source_dedup_skips_total',0),
|
|
47
|
+
('okf_v3_files_written', 0);
|
|
48
|
+
|
|
49
|
+
-- 6. Version marker
|
|
50
|
+
INSERT OR IGNORE INTO schema_version (version) VALUES ('014');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- ============================================================
|
|
2
|
+
-- Kevin v2.1.0 "Relay" — Source deletion + Relay metrics
|
|
3
|
+
-- Migration 015. Forward-only. Additive only.
|
|
4
|
+
-- ============================================================
|
|
5
|
+
|
|
6
|
+
-- 1. Add source provenance column to memories (K21-005)
|
|
7
|
+
-- Stores the MemorySource name (opencode-plugin, claude-memory, codex-memories, opencode-native)
|
|
8
|
+
-- Nullable for legacy rows; new source-inserted rows populate it.
|
|
9
|
+
ALTER TABLE memories ADD COLUMN source TEXT;
|
|
10
|
+
|
|
11
|
+
CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source) WHERE source IS NOT NULL;
|
|
12
|
+
|
|
13
|
+
-- 2. New metric: source_deletions_total (K21-005)
|
|
14
|
+
INSERT OR IGNORE INTO kevin_metrics (key, value) VALUES ('source_deletions_total', 0);
|
|
15
|
+
|
|
16
|
+
-- 3. New settings seed (K21-005, D21-03: opt-in default 0)
|
|
17
|
+
INSERT OR IGNORE INTO kevin_settings (key, value) VALUES ('source_deletion_sync', '0');
|
|
18
|
+
|
|
19
|
+
-- 4. Version marker
|
|
20
|
+
INSERT OR IGNORE INTO schema_version (version) VALUES ('015');
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type OkfEntry, parse } from "./okf.js";
|
|
2
|
+
export declare const SHARD_CAP = 2000;
|
|
3
|
+
export declare const PRIMARY = "knowledge.okf";
|
|
4
|
+
export interface ReadResult {
|
|
5
|
+
entries: OkfEntry[];
|
|
6
|
+
files: string[];
|
|
7
|
+
rejected: ReturnType<typeof parse>["rejected"];
|
|
8
|
+
}
|
|
9
|
+
export declare function readShards(dir: string): ReadResult;
|
|
10
|
+
export declare function writeShards(dir: string, entries: OkfEntry[], repoId: string, version: string, okfVersion?: number): void;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// K16-008 — Shard reader/writer (minimal stub, satisfies typecheck and tests for 1999/2000/2001/4500)
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { MAX_ENTRIES, parse, serialize } from "./okf.js";
|
|
5
|
+
export const SHARD_CAP = MAX_ENTRIES; // 2000
|
|
6
|
+
export const PRIMARY = "knowledge.okf";
|
|
7
|
+
function shardName(n) {
|
|
8
|
+
if (n === 1)
|
|
9
|
+
return PRIMARY;
|
|
10
|
+
return `knowledge-${String(n).padStart(3, "0")}.okf`;
|
|
11
|
+
}
|
|
12
|
+
export function readShards(dir) {
|
|
13
|
+
const files = [];
|
|
14
|
+
const primaryPath = join(dir, PRIMARY);
|
|
15
|
+
if (existsSync(primaryPath))
|
|
16
|
+
files.push(primaryPath);
|
|
17
|
+
// lexicographic shards excluding primary
|
|
18
|
+
const all = existsSync(dir)
|
|
19
|
+
? readdirSync(dir)
|
|
20
|
+
.filter((f) => f.startsWith("knowledge-") && f.endsWith(".okf"))
|
|
21
|
+
.sort()
|
|
22
|
+
: [];
|
|
23
|
+
for (const f of all) {
|
|
24
|
+
const p = join(dir, f);
|
|
25
|
+
if (!files.includes(p))
|
|
26
|
+
files.push(p);
|
|
27
|
+
}
|
|
28
|
+
const entries = [];
|
|
29
|
+
const seen = new Map(); // entry_id -> file
|
|
30
|
+
const rejected = [];
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const txt = readFileSync(file, "utf8");
|
|
33
|
+
const res = parse(txt);
|
|
34
|
+
rejected.push(...res.rejected);
|
|
35
|
+
for (const e of res.entries) {
|
|
36
|
+
const prev = seen.get(e.entry_id);
|
|
37
|
+
if (prev) {
|
|
38
|
+
throw new Error(`okf-shards: duplicate entry_id ${e.entry_id} in ${prev} and ${file}`);
|
|
39
|
+
}
|
|
40
|
+
seen.set(e.entry_id, file);
|
|
41
|
+
entries.push(e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// already sorted? Ensure global sort by entry_id for callers
|
|
45
|
+
entries.sort((a, b) => a.entry_id < b.entry_id ? -1 : a.entry_id > b.entry_id ? 1 : 0);
|
|
46
|
+
return { entries, files, rejected };
|
|
47
|
+
}
|
|
48
|
+
export function writeShards(dir, entries, repoId, version, okfVersion = 2) {
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
// idempotent: pack primary to SHARD_CAP, overflow to shards, collapse sparse gaps, delete empty trailing
|
|
51
|
+
const sorted = [...entries].sort((a, b) => a.entry_id < b.entry_id ? -1 : a.entry_id > b.entry_id ? 1 : 0);
|
|
52
|
+
if (okfVersion === 2) {
|
|
53
|
+
// legacy single-file byte-exact
|
|
54
|
+
const txt = serialize(sorted, repoId, version, 2);
|
|
55
|
+
writeFileSync(join(dir, PRIMARY), txt, "utf8");
|
|
56
|
+
// delete any stray shards
|
|
57
|
+
if (existsSync(dir)) {
|
|
58
|
+
for (const f of readdirSync(dir).filter((x) => x.startsWith("knowledge-") && x.endsWith(".okf"))) {
|
|
59
|
+
try {
|
|
60
|
+
unlinkSync(join(dir, f));
|
|
61
|
+
}
|
|
62
|
+
catch { }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// v3 sharded
|
|
68
|
+
let offset = 0;
|
|
69
|
+
let shardIdx = 1;
|
|
70
|
+
const toKeep = [];
|
|
71
|
+
while (offset < sorted.length || shardIdx === 1) {
|
|
72
|
+
const slice = sorted.slice(offset, offset + SHARD_CAP);
|
|
73
|
+
const name = shardName(shardIdx);
|
|
74
|
+
const path = join(dir, name);
|
|
75
|
+
toKeep.push(path);
|
|
76
|
+
if (slice.length === 0) {
|
|
77
|
+
// delete empty trailing shard if exists
|
|
78
|
+
if (existsSync(path))
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(path);
|
|
81
|
+
}
|
|
82
|
+
catch { }
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
const txt = serialize(slice, repoId, version, 3);
|
|
86
|
+
writeFileSync(path, txt, "utf8");
|
|
87
|
+
offset += SHARD_CAP;
|
|
88
|
+
shardIdx++;
|
|
89
|
+
if (offset >= sorted.length)
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
// delete any shards beyond kept (sparse gaps)
|
|
93
|
+
if (existsSync(dir)) {
|
|
94
|
+
for (const f of readdirSync(dir).filter((x) => x.startsWith("knowledge-") && x.endsWith(".okf"))) {
|
|
95
|
+
const p = join(dir, f);
|
|
96
|
+
if (!toKeep.includes(p) && existsSync(p))
|
|
97
|
+
try {
|
|
98
|
+
unlinkSync(p);
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
}
|
|
102
|
+
// primary must exist even if empty corpus? Write empty header
|
|
103
|
+
if (!existsSync(join(dir, PRIMARY)) && sorted.length === 0) {
|
|
104
|
+
const txt = serialize([], repoId, version, 3);
|
|
105
|
+
writeFileSync(join(dir, PRIMARY), txt, "utf8");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/okf.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** OKF v2 format version marker, written on the first header line. */
|
|
2
2
|
export declare const OKF_VERSION = 2;
|
|
3
|
+
/** v2.0.0 (K16-007) — OKF v3 marker */
|
|
4
|
+
export declare const OKF_V3 = 3;
|
|
5
|
+
export declare const OKF_VERSIONS: readonly [2, 3];
|
|
6
|
+
export type OkfVersion = (typeof OKF_VERSIONS)[number];
|
|
3
7
|
/** A single canonicalized entry line may not exceed this many bytes. */
|
|
4
8
|
export declare const MAX_LINE_BYTES = 4096;
|
|
5
9
|
/** A serialized corpus may not exceed this many entries. */
|
|
@@ -40,7 +44,7 @@ export declare function canonicalize(e: OkfEntry): string;
|
|
|
40
44
|
* byte check) and corpora over MAX_ENTRIES are refused, not
|
|
41
45
|
* truncated (plan §5.3, physical rules).
|
|
42
46
|
*/
|
|
43
|
-
export declare function serialize(entries: OkfEntry[], repoId: string, version: string): string;
|
|
47
|
+
export declare function serialize(entries: OkfEntry[], repoId: string, version: string, okfVersion?: number): string;
|
|
44
48
|
/**
|
|
45
49
|
* Group both corpora by `entry_id`, fold each group through `join()`,
|
|
46
50
|
* and return the result sorted ascending by `entry_id`. Order of the
|
package/dist/okf.js
CHANGED
|
@@ -20,6 +20,9 @@ import { computeConfidence } from "./confidence.js";
|
|
|
20
20
|
import { fnv1a64 } from "./fingerprint.js";
|
|
21
21
|
/** OKF v2 format version marker, written on the first header line. */
|
|
22
22
|
export const OKF_VERSION = 2;
|
|
23
|
+
/** v2.0.0 (K16-007) — OKF v3 marker */
|
|
24
|
+
export const OKF_V3 = 3;
|
|
25
|
+
export const OKF_VERSIONS = [2, 3];
|
|
23
26
|
/** A single canonicalized entry line may not exceed this many bytes. */
|
|
24
27
|
export const MAX_LINE_BYTES = 4096;
|
|
25
28
|
/** A serialized corpus may not exceed this many entries. */
|
|
@@ -58,13 +61,13 @@ export function canonicalize(e) {
|
|
|
58
61
|
* byte check) and corpora over MAX_ENTRIES are refused, not
|
|
59
62
|
* truncated (plan §5.3, physical rules).
|
|
60
63
|
*/
|
|
61
|
-
export function serialize(entries, repoId, version) {
|
|
64
|
+
export function serialize(entries, repoId, version, okfVersion = OKF_VERSION) {
|
|
62
65
|
if (entries.length > MAX_ENTRIES) {
|
|
63
66
|
throw new Error(`okf: corpus of ${entries.length} entries exceeds MAX_ENTRIES (${MAX_ENTRIES})`);
|
|
64
67
|
}
|
|
65
68
|
const sorted = [...entries].sort((a, b) => a.entry_id < b.entry_id ? -1 : a.entry_id > b.entry_id ? 1 : 0);
|
|
66
69
|
const lines = [
|
|
67
|
-
`#okf ${
|
|
70
|
+
`#okf ${okfVersion}`,
|
|
68
71
|
`#repo ${repoId}`,
|
|
69
72
|
`#generated-by opencode-kevin/${version}`,
|
|
70
73
|
];
|
|
@@ -160,7 +163,7 @@ export function parse(text) {
|
|
|
160
163
|
}
|
|
161
164
|
const declared = Number(lines[0].slice(5));
|
|
162
165
|
version = Number.isInteger(declared) && declared >= 0 ? declared : 0;
|
|
163
|
-
if (version >
|
|
166
|
+
if (version > OKF_V3) {
|
|
164
167
|
// Guessing at a future format's semantics is how corpora get
|
|
165
168
|
// corrupted — refuse the whole file, never a best-effort parse.
|
|
166
169
|
return {
|
|
@@ -171,6 +174,10 @@ export function parse(text) {
|
|
|
171
174
|
folded: 0,
|
|
172
175
|
};
|
|
173
176
|
}
|
|
177
|
+
if (version !== OKF_VERSION && version !== OKF_V3) {
|
|
178
|
+
reject(1, "not_okf");
|
|
179
|
+
return { version, repoId: null, entries: [], rejected, folded: 0 };
|
|
180
|
+
}
|
|
174
181
|
if (lines[1]?.startsWith("#repo ")) {
|
|
175
182
|
repoId = lines[1].slice(6) || null;
|
|
176
183
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MemorySource, SourceEntry } from "./MemorySource.js";
|
|
2
|
+
export declare class ClaudeMemorySource implements MemorySource {
|
|
3
|
+
private enabledFlag;
|
|
4
|
+
private root;
|
|
5
|
+
name: string;
|
|
6
|
+
precedence: number;
|
|
7
|
+
constructor(enabledFlag: () => boolean, root?: string);
|
|
8
|
+
enabled(): boolean;
|
|
9
|
+
fetch(): Promise<SourceEntry[]>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// K16-014 — ClaudeMemorySource (read-only mirror of ~/.claude/memory)
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export class ClaudeMemorySource {
|
|
6
|
+
enabledFlag;
|
|
7
|
+
root;
|
|
8
|
+
name = "claude-memory";
|
|
9
|
+
precedence = 20;
|
|
10
|
+
constructor(enabledFlag, root = join(homedir(), ".claude")) {
|
|
11
|
+
this.enabledFlag = enabledFlag;
|
|
12
|
+
this.root = root;
|
|
13
|
+
}
|
|
14
|
+
enabled() {
|
|
15
|
+
return this.enabledFlag();
|
|
16
|
+
}
|
|
17
|
+
async fetch() {
|
|
18
|
+
if (!this.enabled())
|
|
19
|
+
return [];
|
|
20
|
+
const memPath = join(this.root, "memory");
|
|
21
|
+
if (!existsSync(memPath))
|
|
22
|
+
return [];
|
|
23
|
+
const out = [];
|
|
24
|
+
try {
|
|
25
|
+
const st = statSync(memPath);
|
|
26
|
+
if (st.isDirectory()) {
|
|
27
|
+
for (const f of readdirSync(memPath)) {
|
|
28
|
+
if (!f.endsWith(".md") && !f.endsWith(".txt"))
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
const txt = readFileSync(join(memPath, f), "utf8");
|
|
32
|
+
for (const line of txt.split("\n").map((s) => s.trim()).filter(Boolean).slice(0, 50)) {
|
|
33
|
+
if (out.length >= 100)
|
|
34
|
+
break;
|
|
35
|
+
out.push({ statement: line, type: "rule", scope: null, source: this.name });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
if (st.isFile()) {
|
|
43
|
+
const txt = readFileSync(memPath, "utf8");
|
|
44
|
+
return txt
|
|
45
|
+
.split("\n")
|
|
46
|
+
.map((s) => s.trim())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.slice(0, 100)
|
|
49
|
+
.map((statement) => ({
|
|
50
|
+
statement,
|
|
51
|
+
type: "rule",
|
|
52
|
+
scope: null,
|
|
53
|
+
source: this.name,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MemorySource, SourceEntry } from "./MemorySource.js";
|
|
2
|
+
export declare class CodexMemoriesSource implements MemorySource {
|
|
3
|
+
private enabledFlag;
|
|
4
|
+
private root;
|
|
5
|
+
name: string;
|
|
6
|
+
precedence: number;
|
|
7
|
+
constructor(enabledFlag: () => boolean, root?: string);
|
|
8
|
+
enabled(): boolean;
|
|
9
|
+
fetch(): Promise<SourceEntry[]>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// K16-015 — CodexMemoriesSource (mirror of ~/.codex/memories)
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export class CodexMemoriesSource {
|
|
6
|
+
enabledFlag;
|
|
7
|
+
root;
|
|
8
|
+
name = "codex-memories";
|
|
9
|
+
precedence = 30;
|
|
10
|
+
constructor(enabledFlag, root = join(homedir(), ".codex", "memories")) {
|
|
11
|
+
this.enabledFlag = enabledFlag;
|
|
12
|
+
this.root = root;
|
|
13
|
+
}
|
|
14
|
+
enabled() {
|
|
15
|
+
return this.enabledFlag();
|
|
16
|
+
}
|
|
17
|
+
async fetch() {
|
|
18
|
+
if (!this.enabled())
|
|
19
|
+
return [];
|
|
20
|
+
if (!existsSync(this.root))
|
|
21
|
+
return [];
|
|
22
|
+
const out = [];
|
|
23
|
+
try {
|
|
24
|
+
for (const f of readdirSync(this.root)) {
|
|
25
|
+
if (!f.endsWith(".md") && !f.endsWith(".txt"))
|
|
26
|
+
continue;
|
|
27
|
+
try {
|
|
28
|
+
const txt = readFileSync(join(this.root, f), "utf8");
|
|
29
|
+
for (const line of txt
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((s) => s.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.slice(0, 50)) {
|
|
34
|
+
out.push({
|
|
35
|
+
statement: line,
|
|
36
|
+
type: "rule",
|
|
37
|
+
scope: null,
|
|
38
|
+
source: this.name,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch { }
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SharedLayer } from "../SharedLayer.js";
|
|
2
|
+
import type { Store } from "../Store.js";
|
|
3
|
+
import type { MemorySource, SourceSyncResult } from "./MemorySource.js";
|
|
4
|
+
export interface SyncDeps {
|
|
5
|
+
store: Store;
|
|
6
|
+
sources: MemorySource[];
|
|
7
|
+
metrics?: {
|
|
8
|
+
incr(key: string, by?: number): void;
|
|
9
|
+
};
|
|
10
|
+
sharedLayer?: SharedLayer;
|
|
11
|
+
okfPath?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function idleSync(deps: SyncDeps): Promise<SourceSyncResult[]>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { fingerprint } from "../fingerprint.js";
|
|
2
|
+
import { collectDeletions } from "./deletion.js";
|
|
3
|
+
function dedupKey(e) {
|
|
4
|
+
// lower precedence wins attribution: fingerprint over normalized statement + scope
|
|
5
|
+
return fingerprint(`${e.type}\0${e.statement}\0${e.scope ?? ""}`);
|
|
6
|
+
}
|
|
7
|
+
function isDeletionSyncEnabled(store) {
|
|
8
|
+
try {
|
|
9
|
+
const row = store
|
|
10
|
+
.prepare("SELECT value FROM kevin_settings WHERE key = 'source_deletion_sync'")
|
|
11
|
+
.get();
|
|
12
|
+
return row?.value === "1";
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function idleSync(deps) {
|
|
19
|
+
const results = [];
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
// Pre-populate dedup with existing memories fingerprints (lowest precedence already wins)
|
|
22
|
+
try {
|
|
23
|
+
const rows = deps.store
|
|
24
|
+
.prepare("SELECT fingerprint FROM memories")
|
|
25
|
+
.all();
|
|
26
|
+
for (const r of rows)
|
|
27
|
+
if (r.fingerprint)
|
|
28
|
+
seen.add(r.fingerprint);
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
for (const src of deps.sources.sort((a, b) => a.precedence - b.precedence)) {
|
|
32
|
+
if (!src.enabled()) {
|
|
33
|
+
results.push({
|
|
34
|
+
source: src.name,
|
|
35
|
+
fetched: 0,
|
|
36
|
+
dedupSkipped: 0,
|
|
37
|
+
inserted: 0,
|
|
38
|
+
});
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
let entries = [];
|
|
42
|
+
let fetchOk = true;
|
|
43
|
+
try {
|
|
44
|
+
entries = await src.fetch();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
fetchOk = false;
|
|
48
|
+
entries = [];
|
|
49
|
+
}
|
|
50
|
+
// current fingerprints for deletion diff (raw, before dedup)
|
|
51
|
+
const currentFps = new Set();
|
|
52
|
+
for (const e of entries)
|
|
53
|
+
currentFps.add(dedupKey(e));
|
|
54
|
+
let dedupSkipped = 0;
|
|
55
|
+
let inserted = 0;
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const fp = dedupKey(e);
|
|
58
|
+
if (seen.has(fp)) {
|
|
59
|
+
dedupSkipped++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
seen.add(fp);
|
|
63
|
+
// Insert as memory with source provenance (K21-005: source column)
|
|
64
|
+
// Use relevance_score (not confidence) per schema; source col added in 015.
|
|
65
|
+
// Normalize scope: null → 'project' (schema CHECK)
|
|
66
|
+
const normScope = e.scope ?? "project";
|
|
67
|
+
try {
|
|
68
|
+
try {
|
|
69
|
+
deps.store
|
|
70
|
+
.prepare(`INSERT OR IGNORE INTO memories (id, project_id, type, content, scope, fingerprint, relevance_score, origin, source, created_at)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
|
|
72
|
+
.run(`src-${fp.slice(0, 12)}-${src.name}`, "default", e.type, e.statement, normScope, fp, 0.5, "agent", src.name);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// pre-015 DB without source column: store source in source_tool as fallback
|
|
76
|
+
deps.store
|
|
77
|
+
.prepare(`INSERT OR IGNORE INTO memories (id, project_id, type, content, scope, fingerprint, relevance_score, origin, source_tool, created_at)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
|
|
79
|
+
.run(`src-${fp.slice(0, 12)}-${src.name}`, "default", e.type, e.statement, normScope, fp, 0.5, "agent", src.name);
|
|
80
|
+
}
|
|
81
|
+
inserted++;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
dedupSkipped++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (dedupSkipped > 0)
|
|
88
|
+
deps.metrics?.incr("source_dedup_skips_total", dedupSkipped);
|
|
89
|
+
if (inserted > 0)
|
|
90
|
+
deps.metrics?.incr("source_syncs_total", 1);
|
|
91
|
+
// K21-005 — deletion sync: if a previously-saved memory from this source
|
|
92
|
+
// is no longer in the current fetch, archive it (+ tombstone if exported).
|
|
93
|
+
// Gated by source_deletion_sync='1' (opt-in in 2.1.0, D21-03).
|
|
94
|
+
// Only run when fetch succeeded; transient errors must not mass-archive.
|
|
95
|
+
if (fetchOk && isDeletionSyncEnabled(deps.store)) {
|
|
96
|
+
try {
|
|
97
|
+
let prevRows = [];
|
|
98
|
+
try {
|
|
99
|
+
prevRows = deps.store
|
|
100
|
+
.prepare("SELECT id, fingerprint, shared_entry_id, layer FROM memories WHERE source = ? AND status != 'archived' AND fingerprint IS NOT NULL")
|
|
101
|
+
.all(src.name);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// pre-015 DB: source column missing → use source_tool as provenance
|
|
105
|
+
try {
|
|
106
|
+
prevRows = deps.store
|
|
107
|
+
.prepare("SELECT id, fingerprint, shared_entry_id, layer FROM memories WHERE source_tool = ? AND status != 'archived' AND fingerprint IS NOT NULL")
|
|
108
|
+
.all(src.name);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
prevRows = [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const prevFps = new Set();
|
|
115
|
+
const rowByFp = new Map();
|
|
116
|
+
for (const r of prevRows) {
|
|
117
|
+
if (!r.fingerprint)
|
|
118
|
+
continue;
|
|
119
|
+
prevFps.add(r.fingerprint);
|
|
120
|
+
// keep first id per fingerprint
|
|
121
|
+
if (!rowByFp.has(r.fingerprint))
|
|
122
|
+
rowByFp.set(r.fingerprint, { id: r.id, shared_entry_id: r.shared_entry_id, layer: r.layer });
|
|
123
|
+
}
|
|
124
|
+
const deletions = collectDeletions(prevFps, currentFps, src.name);
|
|
125
|
+
for (const d of deletions) {
|
|
126
|
+
const info = rowByFp.get(d.fingerprint);
|
|
127
|
+
if (!info)
|
|
128
|
+
continue;
|
|
129
|
+
// archive locally
|
|
130
|
+
try {
|
|
131
|
+
deps.store
|
|
132
|
+
.prepare("UPDATE memories SET status='archived', archived_at=datetime('now'), updated_at=datetime('now') WHERE id=? AND status!='archived'")
|
|
133
|
+
.run(info.id);
|
|
134
|
+
const ch = deps.store.prepare("SELECT changes() AS c").get();
|
|
135
|
+
if (ch.c === 0)
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// tombstone if ever exported (shared layer)
|
|
142
|
+
const isShared = info.layer === "shared" || info.shared_entry_id !== null;
|
|
143
|
+
if (isShared && deps.sharedLayer && deps.okfPath) {
|
|
144
|
+
try {
|
|
145
|
+
const entryId = info.shared_entry_id;
|
|
146
|
+
if (entryId) {
|
|
147
|
+
const plan = deps.sharedLayer.planTombstone([entryId], deps.okfPath);
|
|
148
|
+
if (plan.write.outcome !== "refused") {
|
|
149
|
+
deps.sharedLayer.applyExport(plan);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// best-effort, never throw
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (!isShared && deps.sharedLayer && deps.okfPath) {
|
|
158
|
+
// For non-shared memories that were previously exported via shared_entries check
|
|
159
|
+
// we attempt tombstone via content-derived entry_id only if a shared entry exists
|
|
160
|
+
try {
|
|
161
|
+
const row = deps.store
|
|
162
|
+
.prepare("SELECT entry_id FROM shared_entries WHERE statement = (SELECT content FROM memories WHERE id=?) LIMIT 1")
|
|
163
|
+
.get(info.id);
|
|
164
|
+
if (row?.entry_id) {
|
|
165
|
+
const plan = deps.sharedLayer.planTombstone([row.entry_id], deps.okfPath);
|
|
166
|
+
if (plan.write.outcome !== "refused")
|
|
167
|
+
deps.sharedLayer.applyExport(plan);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch { }
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
if (deps.metrics) {
|
|
174
|
+
deps.metrics.incr("source_deletions_total", 1);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
deps.store
|
|
178
|
+
.prepare(`INSERT INTO kevin_metrics (key, value, updated_at) VALUES ('source_deletions_total', 1, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = value + 1, updated_at = datetime('now')`)
|
|
179
|
+
.run();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch { }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// best-effort, never break sync
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
results.push({
|
|
190
|
+
source: src.name,
|
|
191
|
+
fetched: entries.length,
|
|
192
|
+
dedupSkipped,
|
|
193
|
+
inserted,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface MemorySource {
|
|
2
|
+
name: string;
|
|
3
|
+
precedence: number;
|
|
4
|
+
enabled(): boolean;
|
|
5
|
+
fetch(): Promise<SourceEntry[]>;
|
|
6
|
+
}
|
|
7
|
+
export interface SourceEntry {
|
|
8
|
+
statement: string;
|
|
9
|
+
type: "decision" | "rule" | "pattern" | "solution";
|
|
10
|
+
scope: string | null;
|
|
11
|
+
source: string;
|
|
12
|
+
updatedAt?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SourceSyncResult {
|
|
15
|
+
source: string;
|
|
16
|
+
fetched: number;
|
|
17
|
+
dedupSkipped: number;
|
|
18
|
+
inserted: number;
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MemorySource, SourceEntry } from "./MemorySource.js";
|
|
2
|
+
export declare const NATIVE_CANDIDATE_PATHS: readonly [".opencode/memory/*.md", ".opencode/MEMORY.md"];
|
|
3
|
+
export declare class OpencodeNativeSource implements MemorySource {
|
|
4
|
+
private enabledFlag;
|
|
5
|
+
private projectRoot;
|
|
6
|
+
name: string;
|
|
7
|
+
precedence: number;
|
|
8
|
+
constructor(enabledFlag: () => boolean, projectRoot?: string);
|
|
9
|
+
enabled(): boolean;
|
|
10
|
+
fetch(): Promise<SourceEntry[]>;
|
|
11
|
+
health(): {
|
|
12
|
+
status: "ok" | "absent";
|
|
13
|
+
detail: string;
|
|
14
|
+
};
|
|
15
|
+
}
|