kodingo-cli 1.0.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 +91 -0
- package/dist/adapters/agent-adapter/agent-connector.js +1 -0
- package/dist/adapters/ai/inference.js +55 -0
- package/dist/adapters/cloud-adapter/cloud-persistence.js +290 -0
- package/dist/adapters/db-adapter/memory-persistence.js +521 -0
- package/dist/adapters/git-adapter/git-listener.js +188 -0
- package/dist/cli.js +181 -0
- package/dist/commands/add-decision.js +1 -0
- package/dist/commands/affirm.js +41 -0
- package/dist/commands/canonicalize-symbol.js +95 -0
- package/dist/commands/capture.js +67 -0
- package/dist/commands/deny.js +67 -0
- package/dist/commands/doctor.js +168 -0
- package/dist/commands/explain-symbol.js +84 -0
- package/dist/commands/ignore.js +19 -0
- package/dist/commands/index.js +1 -0
- package/dist/commands/init.js +135 -0
- package/dist/commands/install-hook.js +61 -0
- package/dist/commands/query-memory.js +61 -0
- package/dist/commands/query-symbol.js +63 -0
- package/dist/commands/scan-git.js +59 -0
- package/dist/commands/uninstall-hook.js +51 -0
- package/dist/ports/cloud-event-port.js +207 -0
- package/dist/ports/db-event-port.js +195 -0
- package/dist/utils/persistence-config.js +69 -0
- package/dist/utils/repo-scope.js +48 -0
- package/package.json +37 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.initDb = initDb;
|
|
4
|
+
exports.checkDatabaseConnection = checkDatabaseConnection;
|
|
5
|
+
exports.saveMemory = saveMemory;
|
|
6
|
+
exports.getMemoryById = getMemoryById;
|
|
7
|
+
exports.updateMemoryLifecycle = updateMemoryLifecycle;
|
|
8
|
+
exports.suppressProposedSiblings = suppressProposedSiblings;
|
|
9
|
+
exports.getMemoryByExternalId = getMemoryByExternalId;
|
|
10
|
+
exports.getProposedOrIgnoredRecordBySymbol = getProposedOrIgnoredRecordBySymbol;
|
|
11
|
+
exports.updateMemoryConfidence = updateMemoryConfidence;
|
|
12
|
+
exports.deleteMemoryById = deleteMemoryById;
|
|
13
|
+
exports.queryMemory = queryMemory;
|
|
14
|
+
exports.queryMemoryBySymbol = queryMemoryBySymbol;
|
|
15
|
+
const node_child_process_1 = require("node:child_process");
|
|
16
|
+
const node_util_1 = require("node:util");
|
|
17
|
+
const uuid_1 = require("uuid");
|
|
18
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
19
|
+
async function initDb() {
|
|
20
|
+
await runPsql(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS kodingo_memory (
|
|
22
|
+
id uuid PRIMARY KEY,
|
|
23
|
+
type text NOT NULL,
|
|
24
|
+
title text NULL,
|
|
25
|
+
content text NOT NULL,
|
|
26
|
+
tags text[] NULL,
|
|
27
|
+
repo_path text NULL,
|
|
28
|
+
|
|
29
|
+
status text NOT NULL DEFAULT 'proposed',
|
|
30
|
+
confidence double precision NOT NULL DEFAULT 0.30,
|
|
31
|
+
symbol text NULL,
|
|
32
|
+
|
|
33
|
+
external_id text NULL,
|
|
34
|
+
|
|
35
|
+
corrects_id uuid NULL,
|
|
36
|
+
corrected_by_id uuid NULL,
|
|
37
|
+
|
|
38
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- Backfill columns (safe on existing installs)
|
|
42
|
+
ALTER TABLE kodingo_memory ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'proposed';
|
|
43
|
+
ALTER TABLE kodingo_memory ADD COLUMN IF NOT EXISTS confidence double precision NOT NULL DEFAULT 0.30;
|
|
44
|
+
ALTER TABLE kodingo_memory ADD COLUMN IF NOT EXISTS symbol text NULL;
|
|
45
|
+
ALTER TABLE kodingo_memory ADD COLUMN IF NOT EXISTS external_id text NULL;
|
|
46
|
+
ALTER TABLE kodingo_memory ADD COLUMN IF NOT EXISTS corrects_id uuid NULL;
|
|
47
|
+
ALTER TABLE kodingo_memory ADD COLUMN IF NOT EXISTS corrected_by_id uuid NULL;
|
|
48
|
+
|
|
49
|
+
-- Indexes
|
|
50
|
+
CREATE INDEX IF NOT EXISTS kodingo_memory_created_at_idx
|
|
51
|
+
ON kodingo_memory (created_at);
|
|
52
|
+
|
|
53
|
+
CREATE INDEX IF NOT EXISTS kodingo_memory_repo_path_idx
|
|
54
|
+
ON kodingo_memory (repo_path);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS kodingo_memory_status_idx
|
|
57
|
+
ON kodingo_memory (status);
|
|
58
|
+
|
|
59
|
+
CREATE INDEX IF NOT EXISTS kodingo_memory_symbol_idx
|
|
60
|
+
ON kodingo_memory (symbol);
|
|
61
|
+
|
|
62
|
+
CREATE INDEX IF NOT EXISTS kodingo_memory_external_id_idx
|
|
63
|
+
ON kodingo_memory (external_id);
|
|
64
|
+
|
|
65
|
+
CREATE INDEX IF NOT EXISTS kodingo_memory_corrects_id_idx
|
|
66
|
+
ON kodingo_memory (corrects_id);
|
|
67
|
+
|
|
68
|
+
CREATE INDEX IF NOT EXISTS kodingo_memory_corrected_by_id_idx
|
|
69
|
+
ON kodingo_memory (corrected_by_id);
|
|
70
|
+
|
|
71
|
+
CREATE INDEX IF NOT EXISTS kodingo_memory_tags_gin_idx
|
|
72
|
+
ON kodingo_memory USING GIN (tags);
|
|
73
|
+
|
|
74
|
+
-- De-dup per repo + external_id (only when external_id exists)
|
|
75
|
+
DO $$
|
|
76
|
+
BEGIN
|
|
77
|
+
IF NOT EXISTS (
|
|
78
|
+
SELECT 1
|
|
79
|
+
FROM pg_indexes
|
|
80
|
+
WHERE indexname = 'kodingo_memory_repo_external_id_uniq'
|
|
81
|
+
) THEN
|
|
82
|
+
EXECUTE 'CREATE UNIQUE INDEX kodingo_memory_repo_external_id_uniq
|
|
83
|
+
ON kodingo_memory (repo_path, external_id)
|
|
84
|
+
WHERE external_id IS NOT NULL';
|
|
85
|
+
END IF;
|
|
86
|
+
END $$;
|
|
87
|
+
|
|
88
|
+
CREATE INDEX IF NOT EXISTS kodingo_memory_content_fts_idx
|
|
89
|
+
ON kodingo_memory
|
|
90
|
+
USING GIN (to_tsvector('english', coalesce(title, '') || ' ' || content));
|
|
91
|
+
`);
|
|
92
|
+
}
|
|
93
|
+
async function checkDatabaseConnection() {
|
|
94
|
+
await runPsql(`SELECT 1;`);
|
|
95
|
+
}
|
|
96
|
+
async function saveMemory(input) {
|
|
97
|
+
const id = (0, uuid_1.v4)();
|
|
98
|
+
const status = input.status ?? "proposed";
|
|
99
|
+
const confidence = clamp01(input.confidence ?? 0.3);
|
|
100
|
+
const sql = `
|
|
101
|
+
WITH inserted AS (
|
|
102
|
+
INSERT INTO kodingo_memory (
|
|
103
|
+
id, type, title, content, tags, repo_path,
|
|
104
|
+
status, confidence, symbol, external_id,
|
|
105
|
+
corrects_id, corrected_by_id
|
|
106
|
+
)
|
|
107
|
+
VALUES (
|
|
108
|
+
${sqlLiteral(id)},
|
|
109
|
+
${sqlLiteral(input.type)},
|
|
110
|
+
${sqlNullableLiteral(input.title)},
|
|
111
|
+
${sqlLiteral(input.content)},
|
|
112
|
+
${sqlArrayLiteral(input.tags)},
|
|
113
|
+
${sqlNullableLiteral(input.repoPath)},
|
|
114
|
+
${sqlLiteral(status)},
|
|
115
|
+
${sqlNumberLiteral(confidence)},
|
|
116
|
+
${sqlNullableLiteral(input.symbol)},
|
|
117
|
+
${sqlNullableLiteral(input.externalId)},
|
|
118
|
+
${sqlNullableUuidLiteral(input.correctsId)},
|
|
119
|
+
${sqlNullableUuidLiteral(input.correctedById)}
|
|
120
|
+
)
|
|
121
|
+
RETURNING
|
|
122
|
+
id, type, title, content, tags, repo_path,
|
|
123
|
+
status, confidence, symbol, external_id,
|
|
124
|
+
corrects_id, corrected_by_id,
|
|
125
|
+
created_at
|
|
126
|
+
)
|
|
127
|
+
SELECT row_to_json(inserted) FROM inserted;
|
|
128
|
+
`;
|
|
129
|
+
const rows = await runPsqlRows(sql);
|
|
130
|
+
const row = rows[0];
|
|
131
|
+
if (!row)
|
|
132
|
+
throw new Error("Failed to insert memory record");
|
|
133
|
+
return mapRow(row);
|
|
134
|
+
}
|
|
135
|
+
async function getMemoryById(id) {
|
|
136
|
+
const sql = `
|
|
137
|
+
SELECT row_to_json(result) FROM (
|
|
138
|
+
SELECT
|
|
139
|
+
id, type, title, content, tags, repo_path,
|
|
140
|
+
status, confidence, symbol, external_id,
|
|
141
|
+
corrects_id, corrected_by_id,
|
|
142
|
+
created_at
|
|
143
|
+
FROM kodingo_memory
|
|
144
|
+
WHERE id = ${sqlLiteral(id)}
|
|
145
|
+
LIMIT 1
|
|
146
|
+
) AS result;
|
|
147
|
+
`;
|
|
148
|
+
const rows = await runPsqlRows(sql);
|
|
149
|
+
const row = rows[0];
|
|
150
|
+
if (!row)
|
|
151
|
+
return null;
|
|
152
|
+
return mapRow(row);
|
|
153
|
+
}
|
|
154
|
+
async function updateMemoryLifecycle(params) {
|
|
155
|
+
const correctedByClause = params.correctedById
|
|
156
|
+
? `, corrected_by_id = ${sqlNullableUuidLiteral(params.correctedById)}`
|
|
157
|
+
: "";
|
|
158
|
+
const sql = `
|
|
159
|
+
WITH updated AS (
|
|
160
|
+
UPDATE kodingo_memory
|
|
161
|
+
SET
|
|
162
|
+
status = ${sqlLiteral(params.status)},
|
|
163
|
+
confidence = ${sqlNumberLiteral(clamp01(params.confidence))}
|
|
164
|
+
${correctedByClause}
|
|
165
|
+
WHERE id = ${sqlLiteral(params.id)}
|
|
166
|
+
RETURNING
|
|
167
|
+
id, type, title, content, tags, repo_path,
|
|
168
|
+
status, confidence, symbol, external_id,
|
|
169
|
+
corrects_id, corrected_by_id,
|
|
170
|
+
created_at
|
|
171
|
+
)
|
|
172
|
+
SELECT row_to_json(updated) FROM updated;
|
|
173
|
+
`;
|
|
174
|
+
const rows = await runPsqlRows(sql);
|
|
175
|
+
const row = rows[0];
|
|
176
|
+
if (!row)
|
|
177
|
+
throw new Error(`Memory record not found: ${params.id}`);
|
|
178
|
+
return mapRow(row);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Suppress stale proposed siblings for a symbol after affirmation.
|
|
182
|
+
*
|
|
183
|
+
* Rule: when a record is affirmed, all other records in the same repo
|
|
184
|
+
* with the same symbol and status=proposed are marked ignored.
|
|
185
|
+
* Denied and ignored records are left untouched.
|
|
186
|
+
*/
|
|
187
|
+
async function suppressProposedSiblings(params) {
|
|
188
|
+
const repoClause = params.repoPath
|
|
189
|
+
? `AND repo_path = ${sqlLiteral(params.repoPath)}`
|
|
190
|
+
: `AND repo_path IS NULL`;
|
|
191
|
+
const sql = `
|
|
192
|
+
WITH suppressed AS (
|
|
193
|
+
UPDATE kodingo_memory
|
|
194
|
+
SET status = 'ignored'
|
|
195
|
+
WHERE symbol = ${sqlLiteral(params.symbol)}
|
|
196
|
+
AND status = 'proposed'
|
|
197
|
+
AND id <> ${sqlLiteral(params.excludeId)}
|
|
198
|
+
${repoClause}
|
|
199
|
+
RETURNING id
|
|
200
|
+
)
|
|
201
|
+
SELECT row_to_json(t) FROM (
|
|
202
|
+
SELECT count(*)::int AS suppressed_count FROM suppressed
|
|
203
|
+
) t;
|
|
204
|
+
`;
|
|
205
|
+
const rows = await runPsqlRows(sql);
|
|
206
|
+
const row = rows[0];
|
|
207
|
+
if (!row)
|
|
208
|
+
return 0;
|
|
209
|
+
const val = row.suppressed_count;
|
|
210
|
+
return typeof val === "number" && Number.isFinite(val) ? val : 0;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Look up a memory record by external_id, scoped to a repo.
|
|
214
|
+
* Used for idempotency checks in git ingestion.
|
|
215
|
+
*/
|
|
216
|
+
async function getMemoryByExternalId(externalId, repoPath) {
|
|
217
|
+
const sql = `
|
|
218
|
+
SELECT row_to_json(result) FROM (
|
|
219
|
+
SELECT
|
|
220
|
+
id, type, title, content, tags, repo_path,
|
|
221
|
+
status, confidence, symbol, external_id,
|
|
222
|
+
corrects_id, corrected_by_id,
|
|
223
|
+
created_at
|
|
224
|
+
FROM kodingo_memory
|
|
225
|
+
WHERE external_id = ${sqlLiteral(externalId)}
|
|
226
|
+
AND repo_path = ${sqlLiteral(repoPath)}
|
|
227
|
+
LIMIT 1
|
|
228
|
+
) AS result;
|
|
229
|
+
`;
|
|
230
|
+
const rows = await runPsqlRows(sql);
|
|
231
|
+
const row = rows[0];
|
|
232
|
+
if (!row)
|
|
233
|
+
return null;
|
|
234
|
+
return mapRow(row);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Find the best proposed or ignored record for a symbol in a repo.
|
|
238
|
+
* Used for confidence accumulation — signals raise existing records
|
|
239
|
+
* rather than inserting duplicates.
|
|
240
|
+
*/
|
|
241
|
+
async function getProposedOrIgnoredRecordBySymbol(params) {
|
|
242
|
+
const sql = `
|
|
243
|
+
SELECT row_to_json(result) FROM (
|
|
244
|
+
SELECT
|
|
245
|
+
id, type, title, content, tags, repo_path,
|
|
246
|
+
status, confidence, symbol, external_id,
|
|
247
|
+
corrects_id, corrected_by_id,
|
|
248
|
+
created_at
|
|
249
|
+
FROM kodingo_memory
|
|
250
|
+
WHERE symbol = ${sqlLiteral(params.symbol)}
|
|
251
|
+
AND repo_path = ${sqlLiteral(params.repoPath)}
|
|
252
|
+
AND status IN ('proposed', 'ignored')
|
|
253
|
+
ORDER BY
|
|
254
|
+
(status = 'proposed') DESC,
|
|
255
|
+
confidence DESC,
|
|
256
|
+
created_at DESC
|
|
257
|
+
LIMIT 1
|
|
258
|
+
) AS result;
|
|
259
|
+
`;
|
|
260
|
+
const rows = await runPsqlRows(sql);
|
|
261
|
+
const row = rows[0];
|
|
262
|
+
if (!row)
|
|
263
|
+
return null;
|
|
264
|
+
return mapRow(row);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Update only the confidence of a memory record.
|
|
268
|
+
* Used for signal accumulation — does not touch status or any other field.
|
|
269
|
+
*/
|
|
270
|
+
async function updateMemoryConfidence(params) {
|
|
271
|
+
const sql = `
|
|
272
|
+
WITH updated AS (
|
|
273
|
+
UPDATE kodingo_memory
|
|
274
|
+
SET confidence = ${sqlNumberLiteral(clamp01(params.confidence))}
|
|
275
|
+
WHERE id = ${sqlLiteral(params.id)}
|
|
276
|
+
RETURNING
|
|
277
|
+
id, type, title, content, tags, repo_path,
|
|
278
|
+
status, confidence, symbol, external_id,
|
|
279
|
+
corrects_id, corrected_by_id,
|
|
280
|
+
created_at
|
|
281
|
+
)
|
|
282
|
+
SELECT row_to_json(updated) FROM updated;
|
|
283
|
+
`;
|
|
284
|
+
const rows = await runPsqlRows(sql);
|
|
285
|
+
const row = rows[0];
|
|
286
|
+
if (!row)
|
|
287
|
+
throw new Error(`Memory record not found: ${params.id}`);
|
|
288
|
+
return mapRow(row);
|
|
289
|
+
}
|
|
290
|
+
async function deleteMemoryById(id) {
|
|
291
|
+
const sql = `
|
|
292
|
+
DELETE FROM kodingo_memory
|
|
293
|
+
WHERE id = ${sqlLiteral(id)};
|
|
294
|
+
`;
|
|
295
|
+
await runPsql(sql);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Text query:
|
|
299
|
+
* - Full-text search on title/content
|
|
300
|
+
* - OR exact tag match when user searches for a tag-like token (e.g. "signal:git")
|
|
301
|
+
*
|
|
302
|
+
* Options:
|
|
303
|
+
* - dedupeBySymbol: return only the best record per symbol (DISTINCT ON symbol).
|
|
304
|
+
* Records with no symbol are never deduped against each other.
|
|
305
|
+
* - includeNonCanonical: include denied records in results.
|
|
306
|
+
* Default behaviour excludes denied; proposed/ignored are always included.
|
|
307
|
+
*/
|
|
308
|
+
async function queryMemory(text, limit = 10, repoPath, opts = {}) {
|
|
309
|
+
const safeLimit = Number.isFinite(limit) ? limit : 10;
|
|
310
|
+
const scopeClause = repoPath ? `AND repo_path = ${sqlLiteral(repoPath)}` : "";
|
|
311
|
+
const deniedClause = opts.includeNonCanonical ? "" : `AND status <> 'denied'`;
|
|
312
|
+
const term = (text ?? "").trim();
|
|
313
|
+
const tagTerm = term;
|
|
314
|
+
if (opts.dedupeBySymbol) {
|
|
315
|
+
// DISTINCT ON (symbol) keeps the best row per symbol, ordered canonically.
|
|
316
|
+
// Rows with NULL symbol are each treated as distinct (no collapse).
|
|
317
|
+
const sql = `
|
|
318
|
+
SELECT row_to_json(result) FROM (
|
|
319
|
+
SELECT DISTINCT ON (coalesce(symbol, id::text))
|
|
320
|
+
id, type, title, content, tags, repo_path,
|
|
321
|
+
status, confidence, symbol, external_id,
|
|
322
|
+
corrects_id, corrected_by_id,
|
|
323
|
+
created_at
|
|
324
|
+
FROM kodingo_memory
|
|
325
|
+
WHERE (
|
|
326
|
+
to_tsvector('english', coalesce(title, '') || ' ' || content)
|
|
327
|
+
@@ plainto_tsquery('english', ${sqlLiteral(term)})
|
|
328
|
+
OR (
|
|
329
|
+
${sqlLiteral(tagTerm)} <> ''
|
|
330
|
+
AND tags IS NOT NULL
|
|
331
|
+
AND tags @> ARRAY[${sqlLiteral(tagTerm)}]::text[]
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
${scopeClause}
|
|
335
|
+
${deniedClause}
|
|
336
|
+
ORDER BY
|
|
337
|
+
coalesce(symbol, id::text),
|
|
338
|
+
(status = 'affirmed') DESC,
|
|
339
|
+
confidence DESC,
|
|
340
|
+
created_at DESC
|
|
341
|
+
LIMIT ${safeLimit}
|
|
342
|
+
) AS result;
|
|
343
|
+
`;
|
|
344
|
+
const rows = await runPsqlRows(sql);
|
|
345
|
+
return rows.map((row) => mapRow(row));
|
|
346
|
+
}
|
|
347
|
+
// Default: canonical-preferred ranking, no symbol deduplication.
|
|
348
|
+
const sql = `
|
|
349
|
+
SELECT row_to_json(result) FROM (
|
|
350
|
+
SELECT
|
|
351
|
+
id, type, title, content, tags, repo_path,
|
|
352
|
+
status, confidence, symbol, external_id,
|
|
353
|
+
corrects_id, corrected_by_id,
|
|
354
|
+
created_at,
|
|
355
|
+
CASE
|
|
356
|
+
WHEN to_tsvector('english', coalesce(title, '') || ' ' || content)
|
|
357
|
+
@@ plainto_tsquery('english', ${sqlLiteral(term)})
|
|
358
|
+
THEN ts_rank(
|
|
359
|
+
to_tsvector('english', coalesce(title, '') || ' ' || content),
|
|
360
|
+
plainto_tsquery('english', ${sqlLiteral(term)})
|
|
361
|
+
)
|
|
362
|
+
ELSE 0
|
|
363
|
+
END AS rank
|
|
364
|
+
FROM kodingo_memory
|
|
365
|
+
WHERE (
|
|
366
|
+
to_tsvector('english', coalesce(title, '') || ' ' || content)
|
|
367
|
+
@@ plainto_tsquery('english', ${sqlLiteral(term)})
|
|
368
|
+
OR (
|
|
369
|
+
${sqlLiteral(tagTerm)} <> ''
|
|
370
|
+
AND tags IS NOT NULL
|
|
371
|
+
AND tags @> ARRAY[${sqlLiteral(tagTerm)}]::text[]
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
${scopeClause}
|
|
375
|
+
${deniedClause}
|
|
376
|
+
ORDER BY
|
|
377
|
+
(status = 'affirmed') DESC,
|
|
378
|
+
rank DESC,
|
|
379
|
+
confidence DESC,
|
|
380
|
+
created_at DESC
|
|
381
|
+
LIMIT ${safeLimit}
|
|
382
|
+
) AS result;
|
|
383
|
+
`;
|
|
384
|
+
const rows = await runPsqlRows(sql);
|
|
385
|
+
return rows.map((row) => mapRow(row));
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Symbol query with canonical preference.
|
|
389
|
+
*
|
|
390
|
+
* Default behavior (when onlyCanonical=false and includeDenied=false):
|
|
391
|
+
* - Prefer affirmed first
|
|
392
|
+
* - Then proposed/ignored
|
|
393
|
+
* - Exclude denied
|
|
394
|
+
*/
|
|
395
|
+
async function queryMemoryBySymbol(params) {
|
|
396
|
+
const safeLimit = Number.isFinite(params.limit ?? 10)
|
|
397
|
+
? (params.limit ?? 10)
|
|
398
|
+
: 10;
|
|
399
|
+
const scopeClause = params.repoPath
|
|
400
|
+
? `AND repo_path = ${sqlLiteral(params.repoPath)}`
|
|
401
|
+
: "";
|
|
402
|
+
let statusClause = "";
|
|
403
|
+
if (params.onlyCanonical === true) {
|
|
404
|
+
statusClause = `AND status = 'affirmed'`;
|
|
405
|
+
}
|
|
406
|
+
else if (params.includeDenied === true) {
|
|
407
|
+
statusClause = "";
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
statusClause = `AND status <> 'denied'`;
|
|
411
|
+
}
|
|
412
|
+
const sql = `
|
|
413
|
+
SELECT row_to_json(result) FROM (
|
|
414
|
+
SELECT
|
|
415
|
+
id, type, title, content, tags, repo_path,
|
|
416
|
+
status, confidence, symbol, external_id,
|
|
417
|
+
corrects_id, corrected_by_id,
|
|
418
|
+
created_at
|
|
419
|
+
FROM kodingo_memory
|
|
420
|
+
WHERE symbol = ${sqlLiteral(params.symbol)}
|
|
421
|
+
${statusClause}
|
|
422
|
+
${scopeClause}
|
|
423
|
+
ORDER BY
|
|
424
|
+
(status = 'affirmed') DESC,
|
|
425
|
+
confidence DESC,
|
|
426
|
+
created_at DESC
|
|
427
|
+
LIMIT ${safeLimit}
|
|
428
|
+
) AS result;
|
|
429
|
+
`;
|
|
430
|
+
const rows = await runPsqlRows(sql);
|
|
431
|
+
return rows.map((row) => mapRow(row));
|
|
432
|
+
}
|
|
433
|
+
function mapRow(row) {
|
|
434
|
+
const status = (row.status ?? "proposed");
|
|
435
|
+
const confidence = clamp01(typeof row.confidence === "number" ? row.confidence : 0.3);
|
|
436
|
+
return {
|
|
437
|
+
id: row.id,
|
|
438
|
+
type: row.type,
|
|
439
|
+
title: row.title ?? null,
|
|
440
|
+
content: row.content,
|
|
441
|
+
tags: row.tags ?? null,
|
|
442
|
+
repoPath: row.repo_path ?? null,
|
|
443
|
+
status,
|
|
444
|
+
confidence,
|
|
445
|
+
symbol: row.symbol ?? null,
|
|
446
|
+
externalId: row.external_id ?? null,
|
|
447
|
+
correctsId: row.corrects_id ?? null,
|
|
448
|
+
correctedById: row.corrected_by_id ?? null,
|
|
449
|
+
createdAt: new Date(row.created_at),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function clamp01(n) {
|
|
453
|
+
if (!Number.isFinite(n))
|
|
454
|
+
return 0.3;
|
|
455
|
+
return Math.max(0, Math.min(1, n));
|
|
456
|
+
}
|
|
457
|
+
async function runPsql(sql) {
|
|
458
|
+
await runPsqlCommand(sql);
|
|
459
|
+
}
|
|
460
|
+
async function runPsqlRows(sql) {
|
|
461
|
+
const output = await runPsqlCommand(sql);
|
|
462
|
+
const trimmed = output.trim();
|
|
463
|
+
if (!trimmed)
|
|
464
|
+
return [];
|
|
465
|
+
return trimmed
|
|
466
|
+
.split("\n")
|
|
467
|
+
.map((line) => line.trim())
|
|
468
|
+
.filter(Boolean)
|
|
469
|
+
.map((line) => JSON.parse(line));
|
|
470
|
+
}
|
|
471
|
+
async function runPsqlCommand(sql) {
|
|
472
|
+
const connectionString = process.env.DATABASE_URL;
|
|
473
|
+
if (!connectionString)
|
|
474
|
+
throw new Error("DATABASE_URL is not set");
|
|
475
|
+
try {
|
|
476
|
+
const { stdout } = await execFileAsync("psql", [
|
|
477
|
+
connectionString,
|
|
478
|
+
"-X",
|
|
479
|
+
"-q",
|
|
480
|
+
"-t",
|
|
481
|
+
"-A",
|
|
482
|
+
"-v",
|
|
483
|
+
"ON_ERROR_STOP=1",
|
|
484
|
+
"-c",
|
|
485
|
+
sql,
|
|
486
|
+
], {
|
|
487
|
+
env: process.env,
|
|
488
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
489
|
+
});
|
|
490
|
+
return stdout;
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
if (error?.code === "ENOENT") {
|
|
494
|
+
throw new Error("psql is required but was not found in PATH");
|
|
495
|
+
}
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
function sqlLiteral(value) {
|
|
500
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
501
|
+
}
|
|
502
|
+
function sqlNullableLiteral(value) {
|
|
503
|
+
if (value === undefined || value === null || value === "")
|
|
504
|
+
return "NULL";
|
|
505
|
+
return sqlLiteral(value);
|
|
506
|
+
}
|
|
507
|
+
function sqlNullableUuidLiteral(value) {
|
|
508
|
+
if (!value)
|
|
509
|
+
return "NULL";
|
|
510
|
+
return `${sqlLiteral(value)}::uuid`;
|
|
511
|
+
}
|
|
512
|
+
function sqlArrayLiteral(values) {
|
|
513
|
+
if (!values || values.length === 0)
|
|
514
|
+
return "NULL";
|
|
515
|
+
return `ARRAY[${values.map((v) => sqlLiteral(v)).join(", ")}]`;
|
|
516
|
+
}
|
|
517
|
+
function sqlNumberLiteral(value) {
|
|
518
|
+
if (!Number.isFinite(value))
|
|
519
|
+
return "0.3";
|
|
520
|
+
return String(value);
|
|
521
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GitListenerAdapter = void 0;
|
|
7
|
+
const simple_git_1 = __importDefault(require("simple-git"));
|
|
8
|
+
const parser_1 = require("@babel/parser");
|
|
9
|
+
const traverse_1 = __importDefault(require("@babel/traverse"));
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
/**
|
|
13
|
+
* Git Adapter
|
|
14
|
+
* - Polls for new commits
|
|
15
|
+
* - Builds a Decision aligned with kodingo-core types
|
|
16
|
+
* - Emits through EventPort (no DB knowledge here)
|
|
17
|
+
*
|
|
18
|
+
* MVP principle: rely primarily on code changes, not commit messages.
|
|
19
|
+
* Commit messages are retained as metadata only.
|
|
20
|
+
*/
|
|
21
|
+
class GitListenerAdapter {
|
|
22
|
+
constructor(repoPath, eventPort, pollInterval = 5000) {
|
|
23
|
+
this.lastProcessedCommit = null;
|
|
24
|
+
this.git = (0, simple_git_1.default)(repoPath);
|
|
25
|
+
this.eventPort = eventPort;
|
|
26
|
+
this.pollInterval = pollInterval;
|
|
27
|
+
this.repoPath = repoPath;
|
|
28
|
+
}
|
|
29
|
+
async init() {
|
|
30
|
+
await this.processLatestCommit();
|
|
31
|
+
this.startWatching();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Run exactly once: process the latest commit only.
|
|
35
|
+
* (Used by Phase 3.1 "run once" command.)
|
|
36
|
+
*/
|
|
37
|
+
async runOnceLatest() {
|
|
38
|
+
await this.processLatestCommit();
|
|
39
|
+
}
|
|
40
|
+
startWatching() {
|
|
41
|
+
setInterval(() => {
|
|
42
|
+
void this.processNewCommits();
|
|
43
|
+
}, this.pollInterval);
|
|
44
|
+
}
|
|
45
|
+
async processNewCommits() {
|
|
46
|
+
const log = await this.git.log({ maxCount: 50 });
|
|
47
|
+
const commits = (log.all ?? []);
|
|
48
|
+
const newCommits = this.takeCommitsSinceLast(commits, this.lastProcessedCommit);
|
|
49
|
+
// Process older -> newer (deterministic evolution)
|
|
50
|
+
for (const commit of newCommits.reverse()) {
|
|
51
|
+
await this.processCommit(commit);
|
|
52
|
+
this.lastProcessedCommit = commit.hash;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* From HEAD backward, collect commits until we reach lastHash.
|
|
57
|
+
* If lastHash is null, return everything passed in.
|
|
58
|
+
*/
|
|
59
|
+
takeCommitsSinceLast(commits, lastHash) {
|
|
60
|
+
if (!lastHash)
|
|
61
|
+
return [...commits];
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const c of commits) {
|
|
64
|
+
if (c.hash === lastHash)
|
|
65
|
+
break;
|
|
66
|
+
out.push(c);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
async processCommit(commit) {
|
|
71
|
+
const projectId = await this.getProjectId();
|
|
72
|
+
const diffFiles = await this.getCommitDiff(commit.hash);
|
|
73
|
+
// Extract JS/TS symbols (best effort) — enrich signal metadata
|
|
74
|
+
const touchedSymbols = this.extractTouchedSymbols(diffFiles);
|
|
75
|
+
// Diff-first rationale: do not trust commit message as "why"
|
|
76
|
+
const decision = {
|
|
77
|
+
id: commit.hash,
|
|
78
|
+
projectId,
|
|
79
|
+
rationale: "Inferred from code changes", // MVP: change-based inference
|
|
80
|
+
changeSummary: this.buildChangeSummary(commit, diffFiles, touchedSymbols),
|
|
81
|
+
confidence: 0.3, // proposed baseline (matches domain contract spirit)
|
|
82
|
+
proposedAt: new Date(commit.date),
|
|
83
|
+
};
|
|
84
|
+
// Core-aligned context: requires priorMemory + source
|
|
85
|
+
// MVP: empty prior memory (DB integration can enrich later)
|
|
86
|
+
const priorMemory = {
|
|
87
|
+
projectId,
|
|
88
|
+
decisions: [],
|
|
89
|
+
updatedAt: new Date(0),
|
|
90
|
+
};
|
|
91
|
+
// kodingo-core currently restricts source to "human" | "system".
|
|
92
|
+
// Git ingestion is an automated system signal, so use "system".
|
|
93
|
+
const context = {
|
|
94
|
+
priorMemory,
|
|
95
|
+
source: "system",
|
|
96
|
+
};
|
|
97
|
+
await this.eventPort.pushDecision(decision, context);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Stable summary string that keeps useful Git metadata.
|
|
101
|
+
* Commit message stored as metadata only (low-trust).
|
|
102
|
+
*/
|
|
103
|
+
buildChangeSummary(commit, diffFiles, symbols) {
|
|
104
|
+
const files = diffFiles.map((f) => ({
|
|
105
|
+
file: f.file,
|
|
106
|
+
insertions: f.insertions,
|
|
107
|
+
deletions: f.deletions,
|
|
108
|
+
binary: f.binary,
|
|
109
|
+
}));
|
|
110
|
+
const summaryObj = {
|
|
111
|
+
source: "git",
|
|
112
|
+
commitHash: commit.hash,
|
|
113
|
+
commitMessage: commit.message, // metadata only
|
|
114
|
+
author: commit.author_name,
|
|
115
|
+
filesChanged: files,
|
|
116
|
+
symbolsTouched: symbols,
|
|
117
|
+
};
|
|
118
|
+
return JSON.stringify(summaryObj);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Diff summary for a single commit.
|
|
122
|
+
*/
|
|
123
|
+
async getCommitDiff(commitHash) {
|
|
124
|
+
const diffSummary = await this.git.diffSummary([`${commitHash}^!`]);
|
|
125
|
+
return (diffSummary.files ?? []).map((f) => ({
|
|
126
|
+
file: f.file,
|
|
127
|
+
insertions: typeof f.insertions === "number" ? f.insertions : 0,
|
|
128
|
+
deletions: typeof f.deletions === "number" ? f.deletions : 0,
|
|
129
|
+
changes: typeof f.changes === "number" ? f.changes : 0,
|
|
130
|
+
binary: !!f.binary,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
async getProjectId() {
|
|
134
|
+
const remotes = await this.git.getRemotes(true);
|
|
135
|
+
const fetchUrl = remotes?.[0]?.refs?.fetch;
|
|
136
|
+
return fetchUrl || "local-project";
|
|
137
|
+
}
|
|
138
|
+
async processLatestCommit() {
|
|
139
|
+
const log = await this.git.log({ maxCount: 1 });
|
|
140
|
+
const commits = (log.all ?? []);
|
|
141
|
+
const latestCommit = commits[0];
|
|
142
|
+
if (!latestCommit)
|
|
143
|
+
return;
|
|
144
|
+
await this.processCommit(latestCommit);
|
|
145
|
+
this.lastProcessedCommit = latestCommit.hash;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Extract touched function/class names for JS/TS files (best effort).
|
|
149
|
+
* NOTE: Parses working tree file, not historical snapshot (acceptable for MVP).
|
|
150
|
+
*/
|
|
151
|
+
extractTouchedSymbols(diffFiles) {
|
|
152
|
+
const symbols = [];
|
|
153
|
+
for (const file of diffFiles) {
|
|
154
|
+
if (file.binary)
|
|
155
|
+
continue;
|
|
156
|
+
const isTsOrJs = file.file.endsWith(".ts") || file.file.endsWith(".js");
|
|
157
|
+
if (!isTsOrJs)
|
|
158
|
+
continue;
|
|
159
|
+
const absFilePath = path_1.default.join(this.repoPath, file.file);
|
|
160
|
+
if (!fs_1.default.existsSync(absFilePath))
|
|
161
|
+
continue;
|
|
162
|
+
const content = fs_1.default.readFileSync(absFilePath, "utf-8");
|
|
163
|
+
try {
|
|
164
|
+
const ast = (0, parser_1.parse)(content, {
|
|
165
|
+
sourceType: "module",
|
|
166
|
+
plugins: ["typescript", "classProperties"],
|
|
167
|
+
});
|
|
168
|
+
(0, traverse_1.default)(ast, {
|
|
169
|
+
FunctionDeclaration(p) {
|
|
170
|
+
const name = p?.node?.id?.name;
|
|
171
|
+
if (name)
|
|
172
|
+
symbols.push(`function:${name}`);
|
|
173
|
+
},
|
|
174
|
+
ClassDeclaration(p) {
|
|
175
|
+
const name = p?.node?.id?.name;
|
|
176
|
+
if (name)
|
|
177
|
+
symbols.push(`class:${name}`);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Ignore parse errors for MVP
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return Array.from(new Set(symbols));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
exports.GitListenerAdapter = GitListenerAdapter;
|