@kage-core/kage-graph-mcp 2.1.0 → 2.2.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/dist/cli.js +123 -6
- package/dist/index.js +28 -2
- package/dist/kernel.js +1361 -24
- package/package.json +1 -1
package/dist/kernel.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.AUTO_DISTILL_TAG = exports.SETUP_AGENTS = exports.MEMORY_TYPES = exports.PACKET_SCHEMA_VERSION = void 0;
|
|
36
|
+
exports.PACKET_MERGE_DRIVER_CONFIG = exports.PACKET_MERGE_ATTRIBUTE_LINE = exports.AUTO_DISTILL_SIGNAL_THRESHOLD = exports.AUTO_DISTILL_TAG = exports.SETUP_AGENTS = exports.MEMORY_TYPES = exports.PACKET_SCHEMA_VERSION = void 0;
|
|
37
37
|
exports.memoryRoot = memoryRoot;
|
|
38
38
|
exports.packetsDir = packetsDir;
|
|
39
39
|
exports.pendingDir = pendingDir;
|
|
@@ -65,6 +65,7 @@ exports.kageMemoryLifecycle = kageMemoryLifecycle;
|
|
|
65
65
|
exports.recordValueEvent = recordValueEvent;
|
|
66
66
|
exports.valueSummary = valueSummary;
|
|
67
67
|
exports.formatTokenCount = formatTokenCount;
|
|
68
|
+
exports.kageFileContext = kageFileContext;
|
|
68
69
|
exports.kageActivity = kageActivity;
|
|
69
70
|
exports.kageMemoryReconciliation = kageMemoryReconciliation;
|
|
70
71
|
exports.evaluateMemoryAdmission = evaluateMemoryAdmission;
|
|
@@ -104,6 +105,14 @@ exports.kageRisk = kageRisk;
|
|
|
104
105
|
exports.kageDependencyPath = kageDependencyPath;
|
|
105
106
|
exports.kageCleanupCandidates = kageCleanupCandidates;
|
|
106
107
|
exports.truthReport = truthReport;
|
|
108
|
+
exports.defaultClaudeMemStorePath = defaultClaudeMemStorePath;
|
|
109
|
+
exports.claudeMemProjectKey = claudeMemProjectKey;
|
|
110
|
+
exports.parseClaudeMemFileList = parseClaudeMemFileList;
|
|
111
|
+
exports.readClaudeMemObservations = readClaudeMemObservations;
|
|
112
|
+
exports.buildClaudeMemChangeSignal = buildClaudeMemChangeSignal;
|
|
113
|
+
exports.classifyClaudeMemObservations = classifyClaudeMemObservations;
|
|
114
|
+
exports.auditClaudeMemStore = auditClaudeMemStore;
|
|
115
|
+
exports.renderClaudeMemAuditReceipt = renderClaudeMemAuditReceipt;
|
|
107
116
|
exports.kageReviewerSuggestions = kageReviewerSuggestions;
|
|
108
117
|
exports.kageContributors = kageContributors;
|
|
109
118
|
exports.kageContextSlots = kageContextSlots;
|
|
@@ -137,6 +146,7 @@ exports.setupAgent = setupAgent;
|
|
|
137
146
|
exports.setupDoctor = setupDoctor;
|
|
138
147
|
exports.verifyAgentActivation = verifyAgentActivation;
|
|
139
148
|
exports.observe = observe;
|
|
149
|
+
exports.observationSignalScore = observationSignalScore;
|
|
140
150
|
exports.kageSessionCaptureReport = kageSessionCaptureReport;
|
|
141
151
|
exports.kageSessionReplay = kageSessionReplay;
|
|
142
152
|
exports.kageSessionLearningLedger = kageSessionLearningLedger;
|
|
@@ -158,6 +168,8 @@ exports.validateProject = validateProject;
|
|
|
158
168
|
exports.initProject = initProject;
|
|
159
169
|
exports.doctorProject = doctorProject;
|
|
160
170
|
exports.splitConflictSides = splitConflictSides;
|
|
171
|
+
exports.mergePacketFiles = mergePacketFiles;
|
|
172
|
+
exports.ensurePacketMergeAttributes = ensurePacketMergeAttributes;
|
|
161
173
|
exports.repairProject = repairProject;
|
|
162
174
|
exports.remediationFor = remediationFor;
|
|
163
175
|
exports.approvePending = approvePending;
|
|
@@ -166,6 +178,16 @@ exports.changelog = changelog;
|
|
|
166
178
|
exports.supersedeMemory = supersedeMemory;
|
|
167
179
|
exports.kageMemoryLineage = kageMemoryLineage;
|
|
168
180
|
exports.kageMemoryTimeline = kageMemoryTimeline;
|
|
181
|
+
exports.kageHomeDir = kageHomeDir;
|
|
182
|
+
exports.personalMemoryDir = personalMemoryDir;
|
|
183
|
+
exports.personalPacketsDir = personalPacketsDir;
|
|
184
|
+
exports.personalConflictsDir = personalConflictsDir;
|
|
185
|
+
exports.loadPersonalPackets = loadPersonalPackets;
|
|
186
|
+
exports.learnPersonal = learnPersonal;
|
|
187
|
+
exports.capturePersonal = capturePersonal;
|
|
188
|
+
exports.syncSetup = syncSetup;
|
|
189
|
+
exports.syncStatus = syncStatus;
|
|
190
|
+
exports.syncPersonal = syncPersonal;
|
|
169
191
|
const node_crypto_1 = require("node:crypto");
|
|
170
192
|
const node_child_process_1 = require("node:child_process");
|
|
171
193
|
const node_fs_1 = require("node:fs");
|
|
@@ -981,7 +1003,7 @@ function valueLedgerPath(projectDir) {
|
|
|
981
1003
|
function emptyValueLedger() {
|
|
982
1004
|
return {
|
|
983
1005
|
schema_version: VALUE_LEDGER_SCHEMA_VERSION,
|
|
984
|
-
totals: { tokens_saved: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 },
|
|
1006
|
+
totals: { tokens_saved: 0, replay_tokens: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 },
|
|
985
1007
|
events: [],
|
|
986
1008
|
};
|
|
987
1009
|
}
|
|
@@ -1009,6 +1031,7 @@ function readValueLedger(projectDir) {
|
|
|
1009
1031
|
schema_version: VALUE_LEDGER_SCHEMA_VERSION,
|
|
1010
1032
|
totals: {
|
|
1011
1033
|
tokens_saved: nonNegativeCount(totals.tokens_saved),
|
|
1034
|
+
replay_tokens: nonNegativeCount(totals.replay_tokens),
|
|
1012
1035
|
stale_withheld: nonNegativeCount(totals.stale_withheld),
|
|
1013
1036
|
stale_caught: nonNegativeCount(totals.stale_caught),
|
|
1014
1037
|
recalls: nonNegativeCount(totals.recalls),
|
|
@@ -1031,7 +1054,9 @@ function recordValueEvents(projectDir, events) {
|
|
|
1031
1054
|
const record = { at, kind: event.kind };
|
|
1032
1055
|
if (event.kind === "recall_served") {
|
|
1033
1056
|
record.tokens_saved = nonNegativeCount(event.tokens_saved);
|
|
1057
|
+
record.replay_tokens = nonNegativeCount(event.replay_tokens);
|
|
1034
1058
|
ledger.totals.tokens_saved += record.tokens_saved;
|
|
1059
|
+
ledger.totals.replay_tokens += record.replay_tokens;
|
|
1035
1060
|
ledger.totals.recalls += 1;
|
|
1036
1061
|
}
|
|
1037
1062
|
else if (event.kind === "stale_withheld") {
|
|
@@ -1068,7 +1093,7 @@ function estimatedTokenDollars(tokensSaved) {
|
|
|
1068
1093
|
return Number(((tokensSaved / 1_000_000) * VALUE_DOLLARS_PER_MILLION_TOKENS).toFixed(2));
|
|
1069
1094
|
}
|
|
1070
1095
|
function summarizeValueWindow(events, cutoff) {
|
|
1071
|
-
const window = { tokens_saved: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 };
|
|
1096
|
+
const window = { tokens_saved: 0, replay_tokens: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 };
|
|
1072
1097
|
for (const event of events) {
|
|
1073
1098
|
const at = Date.parse(event.at);
|
|
1074
1099
|
if (!Number.isFinite(at) || at < cutoff)
|
|
@@ -1076,6 +1101,7 @@ function summarizeValueWindow(events, cutoff) {
|
|
|
1076
1101
|
if (event.kind === "recall_served") {
|
|
1077
1102
|
window.recalls += 1;
|
|
1078
1103
|
window.tokens_saved += nonNegativeCount(event.tokens_saved);
|
|
1104
|
+
window.replay_tokens += nonNegativeCount(event.replay_tokens);
|
|
1079
1105
|
}
|
|
1080
1106
|
else if (event.kind === "stale_withheld") {
|
|
1081
1107
|
window.stale_withheld += 1;
|
|
@@ -1128,6 +1154,89 @@ function recallTokensSaved(projectDir, results, contextBlock) {
|
|
|
1128
1154
|
}
|
|
1129
1155
|
return Math.max(0, Math.floor(sourceBytes / 4) - Math.floor(contextBlock.length / 4));
|
|
1130
1156
|
}
|
|
1157
|
+
// Conservative per-type defaults for discovery_tokens — the approximate exploration +
|
|
1158
|
+
// reasoning tokens an agent typically burns to produce knowledge of this type — used
|
|
1159
|
+
// when a capture does not report its actual discovery cost. Deliberately low so
|
|
1160
|
+
// knowledge-replay receipts under-claim rather than over-claim.
|
|
1161
|
+
const DEFAULT_DISCOVERY_TOKENS = {
|
|
1162
|
+
bug_fix: 8000,
|
|
1163
|
+
gotcha: 8000,
|
|
1164
|
+
decision: 4000,
|
|
1165
|
+
};
|
|
1166
|
+
const DEFAULT_DISCOVERY_TOKENS_FALLBACK = 2000;
|
|
1167
|
+
function defaultDiscoveryTokens(type) {
|
|
1168
|
+
return DEFAULT_DISCOVERY_TOKENS[type] ?? DEFAULT_DISCOVERY_TOKENS_FALLBACK;
|
|
1169
|
+
}
|
|
1170
|
+
function packetDiscoveryTokens(packet) {
|
|
1171
|
+
const value = Number(packet.quality?.discovery_tokens);
|
|
1172
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
1173
|
+
}
|
|
1174
|
+
// Knowledge replay value: the tokens originally spent discovering the served packets'
|
|
1175
|
+
// knowledge minus the compressed cost of re-reading them as context. Floored at zero.
|
|
1176
|
+
// Recall receipts take the max of this and the read-vs-source estimate, so reported
|
|
1177
|
+
// savings never drop below the pre-discovery_tokens behavior and never go negative.
|
|
1178
|
+
function replayTokensSaved(packets, contextBlock) {
|
|
1179
|
+
const discovery = packets.reduce((sum, packet) => sum + packetDiscoveryTokens(packet), 0);
|
|
1180
|
+
if (discovery <= 0)
|
|
1181
|
+
return 0;
|
|
1182
|
+
return Math.max(0, discovery - estimateTokens(contextBlock));
|
|
1183
|
+
}
|
|
1184
|
+
const FILE_CONTEXT_PACKET_CAP = 3;
|
|
1185
|
+
// PreToolUse(Read) injection: verified memory at the moment of relevance. Returns at
|
|
1186
|
+
// most three currently-verified packets that cite the file an agent is about to read,
|
|
1187
|
+
// as a compact context block. Reuses the same staleness machinery as recall — a packet
|
|
1188
|
+
// with ANY stale reason (deprecated/superseded, reported stale, ttl expired, missing or
|
|
1189
|
+
// changed citations) is never injected, so the block only ever carries verified memory.
|
|
1190
|
+
function kageFileContext(projectDir, filePath) {
|
|
1191
|
+
const result = {
|
|
1192
|
+
schema_version: 1,
|
|
1193
|
+
project_dir: (0, node_path_1.resolve)(projectDir),
|
|
1194
|
+
path: "",
|
|
1195
|
+
packets: [],
|
|
1196
|
+
context_block: "",
|
|
1197
|
+
};
|
|
1198
|
+
if (!filePath || !filePath.trim())
|
|
1199
|
+
return result;
|
|
1200
|
+
let rel = filePath.trim().replace(/\\/g, "/");
|
|
1201
|
+
if ((0, node_path_1.isAbsolute)(rel))
|
|
1202
|
+
rel = (0, node_path_1.relative)((0, node_path_1.resolve)(projectDir), rel).replace(/\\/g, "/");
|
|
1203
|
+
rel = rel.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
1204
|
+
result.path = rel;
|
|
1205
|
+
// Files outside the project (or an uninitialized project) never produce context.
|
|
1206
|
+
if (!rel || rel.startsWith("..") || !(0, node_fs_1.existsSync)(memoryRoot(projectDir)))
|
|
1207
|
+
return result;
|
|
1208
|
+
const fingerprintCache = new Map();
|
|
1209
|
+
const qualityScore = (packet) => {
|
|
1210
|
+
const score = Number(packet.quality?.score);
|
|
1211
|
+
return Number.isFinite(score) ? score : 0;
|
|
1212
|
+
};
|
|
1213
|
+
const verified = loadApprovedPackets(projectDir)
|
|
1214
|
+
.filter((packet) => packetPathSet(packet).has(rel))
|
|
1215
|
+
.filter((packet) => staleMemoryReasons(projectDir, packet, fingerprintCache).length === 0)
|
|
1216
|
+
.sort((a, b) => qualityScore(b) - qualityScore(a) || b.updated_at.localeCompare(a.updated_at) || a.title.localeCompare(b.title))
|
|
1217
|
+
.slice(0, FILE_CONTEXT_PACKET_CAP);
|
|
1218
|
+
if (!verified.length)
|
|
1219
|
+
return result;
|
|
1220
|
+
const lines = [
|
|
1221
|
+
`# Kage File Context: ${rel}`,
|
|
1222
|
+
...verified.flatMap((packet, index) => [
|
|
1223
|
+
`${index + 1}. [${packet.type} | confidence ${packet.confidence.toFixed(2)}] ${packet.title}`,
|
|
1224
|
+
` ${packet.summary}`,
|
|
1225
|
+
]),
|
|
1226
|
+
`_${verified.length} verified memor${verified.length === 1 ? "y" : "ies"} citing this file (citations checked, not stale)._`,
|
|
1227
|
+
];
|
|
1228
|
+
result.context_block = lines.join("\n");
|
|
1229
|
+
result.packets = verified.map((packet) => ({
|
|
1230
|
+
id: packet.id,
|
|
1231
|
+
title: packet.title,
|
|
1232
|
+
type: packet.type,
|
|
1233
|
+
summary: packet.summary,
|
|
1234
|
+
confidence: packet.confidence,
|
|
1235
|
+
}));
|
|
1236
|
+
const replay = replayTokensSaved(verified, result.context_block);
|
|
1237
|
+
recordValueEvent(projectDir, { kind: "recall_served", tokens_saved: replay, replay_tokens: replay });
|
|
1238
|
+
return result;
|
|
1239
|
+
}
|
|
1131
1240
|
const AUDIT_ACTIVITY_KIND = {
|
|
1132
1241
|
capture: "capture", approve: "capture", supersede: "supersede", deprecate: "deprecate",
|
|
1133
1242
|
update: "update", promote: "promote", feedback: "feedback",
|
|
@@ -2232,6 +2341,28 @@ function safeReadText(path) {
|
|
|
2232
2341
|
function gitBranch(projectDir) {
|
|
2233
2342
|
return readGit(projectDir, ["branch", "--show-current"]) || readGit(projectDir, ["rev-parse", "--short", "HEAD"]);
|
|
2234
2343
|
}
|
|
2344
|
+
function gitDefaultBranch(projectDir) {
|
|
2345
|
+
// Prefer the remote's view of the default branch (origin/HEAD), then fall
|
|
2346
|
+
// back to whichever of master/main exists locally.
|
|
2347
|
+
const originHead = readGit(projectDir, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
2348
|
+
if (originHead)
|
|
2349
|
+
return originHead.replace(/^origin\//, "");
|
|
2350
|
+
for (const candidate of ["master", "main"]) {
|
|
2351
|
+
if (readGit(projectDir, ["rev-parse", "--verify", "--quiet", `refs/heads/${candidate}`]) !== null)
|
|
2352
|
+
return candidate;
|
|
2353
|
+
}
|
|
2354
|
+
return null;
|
|
2355
|
+
}
|
|
2356
|
+
// True only when we are confident the working tree is on a non-default branch.
|
|
2357
|
+
// Unknown states (not a git repo, detached HEAD, undeterminable default branch)
|
|
2358
|
+
// return false so refresh keeps its full-rewrite behavior.
|
|
2359
|
+
function onNonDefaultBranch(projectDir) {
|
|
2360
|
+
const current = readGit(projectDir, ["branch", "--show-current"]);
|
|
2361
|
+
if (!current)
|
|
2362
|
+
return false;
|
|
2363
|
+
const defaultBranch = gitDefaultBranch(projectDir);
|
|
2364
|
+
return defaultBranch !== null && current !== defaultBranch;
|
|
2365
|
+
}
|
|
2235
2366
|
function gitHead(projectDir) {
|
|
2236
2367
|
return readGit(projectDir, ["rev-parse", "HEAD"]);
|
|
2237
2368
|
}
|
|
@@ -6082,7 +6213,12 @@ function staleFinding(packet, reasons) {
|
|
|
6082
6213
|
suggested_action: staleSuggestedAction(reasons),
|
|
6083
6214
|
};
|
|
6084
6215
|
}
|
|
6085
|
-
|
|
6216
|
+
// quiet: compute staleness fully (findings still drive recall withholding) but
|
|
6217
|
+
// skip pure-metadata rewrites (stale flags / updated_at recomputation) on disk.
|
|
6218
|
+
// Used on non-default git branches so concurrent branches stop conflicting on
|
|
6219
|
+
// cosmetic packet churn. Content changes (pruned grounding paths, i.e. the
|
|
6220
|
+
// citation set changed) are still persisted even in quiet mode.
|
|
6221
|
+
function refreshPacketStaleness(projectDir, options = {}) {
|
|
6086
6222
|
const findings = [];
|
|
6087
6223
|
let updated = 0;
|
|
6088
6224
|
const fingerprintCache = new Map();
|
|
@@ -6111,10 +6247,11 @@ function refreshPacketStaleness(projectDir) {
|
|
|
6111
6247
|
nextQuality = rest;
|
|
6112
6248
|
}
|
|
6113
6249
|
const nextFreshness = oldFreshness;
|
|
6114
|
-
const
|
|
6250
|
+
const contentChanged = pruned !== null;
|
|
6251
|
+
const changed = contentChanged
|
|
6115
6252
|
|| JSON.stringify(oldQuality) !== JSON.stringify(nextQuality)
|
|
6116
6253
|
|| JSON.stringify(oldFreshness) !== JSON.stringify(nextFreshness);
|
|
6117
|
-
if (changed) {
|
|
6254
|
+
if (changed && (!options.quiet || contentChanged)) {
|
|
6118
6255
|
writeJson(entry.path, {
|
|
6119
6256
|
...packet,
|
|
6120
6257
|
freshness: nextFreshness,
|
|
@@ -6127,11 +6264,17 @@ function refreshPacketStaleness(projectDir) {
|
|
|
6127
6264
|
return { findings, updated };
|
|
6128
6265
|
}
|
|
6129
6266
|
function refreshProject(projectDir, options = {}) {
|
|
6267
|
+
// Quiet-refresh on non-default branches: staleness is still computed (and
|
|
6268
|
+
// recall withholding still works — it recomputes staleness in memory), but
|
|
6269
|
+
// metadata-only packet rewrites are not persisted, so concurrent branches
|
|
6270
|
+
// stop generating merge conflicts on .agent_memory/packets/*.json.
|
|
6271
|
+
// --force restores full rewrites anywhere.
|
|
6272
|
+
const quiet = !options.force && onNonDefaultBranch(projectDir);
|
|
6130
6273
|
const detailedIndex = indexProjectDetailed(projectDir, { full: options.full });
|
|
6131
6274
|
const index = detailedIndex.result;
|
|
6132
6275
|
let codeGraph = detailedIndex.codeGraph;
|
|
6133
6276
|
let knowledgeGraph = detailedIndex.knowledgeGraph;
|
|
6134
|
-
const stale = refreshPacketStaleness(projectDir);
|
|
6277
|
+
const stale = refreshPacketStaleness(projectDir, { quiet });
|
|
6135
6278
|
let indexes = index.indexes;
|
|
6136
6279
|
if (stale.updated > 0) {
|
|
6137
6280
|
const rebuilt = buildGraphIndexes(projectDir, { forceCodeGraph: options.full });
|
|
@@ -6145,6 +6288,9 @@ function refreshProject(projectDir, options = {}) {
|
|
|
6145
6288
|
writeJson((0, node_path_1.join)(reportsDir(projectDir), "context-slots.json"), kageContextSlots(projectDir));
|
|
6146
6289
|
writeJson((0, node_path_1.join)(reportsDir(projectDir), "handoff.json"), kageMemoryHandoff(projectDir));
|
|
6147
6290
|
const nextActions = [];
|
|
6291
|
+
if (quiet && stale.findings.length) {
|
|
6292
|
+
nextActions.push("Quiet refresh (non-default branch): stale flags were computed in memory but not written to packet files. Run `kage refresh --force` to persist them.");
|
|
6293
|
+
}
|
|
6148
6294
|
if (stale.findings.length)
|
|
6149
6295
|
nextActions.push("Update, verify, or supersede stale repo memories before relying on them.");
|
|
6150
6296
|
if (!validation.ok)
|
|
@@ -6157,6 +6303,7 @@ function refreshProject(projectDir, options = {}) {
|
|
|
6157
6303
|
ok: validation.ok,
|
|
6158
6304
|
project_dir: projectDir,
|
|
6159
6305
|
generated_at: nowIso(),
|
|
6306
|
+
quiet_refresh: quiet,
|
|
6160
6307
|
index,
|
|
6161
6308
|
validation,
|
|
6162
6309
|
metrics,
|
|
@@ -7202,6 +7349,11 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7202
7349
|
return true;
|
|
7203
7350
|
})
|
|
7204
7351
|
.slice(0, 3);
|
|
7352
|
+
// Personal memory (~/.kage/memory): a clearly separated, lower-trust section
|
|
7353
|
+
// appended AFTER every repo section — repo memory always ranks first. Cited
|
|
7354
|
+
// personal packets are re-verified against this checkout (hard-stale ones are
|
|
7355
|
+
// withheld, same as repo memory); citation-free ones are labeled unverifiable.
|
|
7356
|
+
const personalEntries = personalRecallEntries(projectDir, terms, 3);
|
|
7205
7357
|
const graphContext = queryGraph(projectDir, query, 5, knowledgeGraph);
|
|
7206
7358
|
const codeContext = queryCodeGraph(projectDir, query, 5, codeGraph);
|
|
7207
7359
|
const pinnedContext = renderPinnedRepoContext(readContextSlots(projectDir));
|
|
@@ -7250,6 +7402,20 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7250
7402
|
...(suppressed.length
|
|
7251
7403
|
? ["", `_${suppressed.length} stale memory packet(s) excluded from recall. Run kage verify for details._`]
|
|
7252
7404
|
: []),
|
|
7405
|
+
...(personalEntries.length
|
|
7406
|
+
? [
|
|
7407
|
+
"",
|
|
7408
|
+
"## Personal Memory",
|
|
7409
|
+
"_Cross-machine personal store (~/.kage/memory). Lower trust than repo memory: not repo-reviewed — verify before relying on it. Repo memory above takes precedence on conflict._",
|
|
7410
|
+
...personalEntries.flatMap((entry, index) => [
|
|
7411
|
+
"",
|
|
7412
|
+
`${index + 1}. [personal] [${entry.packet.type} | confidence ${entry.packet.confidence.toFixed(2)}] ${entry.packet.title}`,
|
|
7413
|
+
` [personal] Summary: ${entry.packet.summary}`,
|
|
7414
|
+
` [personal] Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
|
|
7415
|
+
` [personal] Verification: ${entry.unverifiable ? "unverifiable (citation-free personal note)" : "citations re-verified against this checkout"}`,
|
|
7416
|
+
]),
|
|
7417
|
+
]
|
|
7418
|
+
: []),
|
|
7253
7419
|
];
|
|
7254
7420
|
const assembledBlock = lines.join("\n");
|
|
7255
7421
|
const result = {
|
|
@@ -7257,6 +7423,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7257
7423
|
context_block: inputs.maxContextTokens ? boundContextBlock(assembledBlock, inputs.maxContextTokens) : assembledBlock,
|
|
7258
7424
|
results: scored,
|
|
7259
7425
|
suppressed: suppressed.length ? suppressed : undefined,
|
|
7426
|
+
personal: personalEntries.length ? personalEntries : undefined,
|
|
7260
7427
|
explanations: explain
|
|
7261
7428
|
? scored.map((entry) => ({
|
|
7262
7429
|
packet_id: entry.packet.id,
|
|
@@ -7267,15 +7434,20 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7267
7434
|
}))
|
|
7268
7435
|
: undefined,
|
|
7269
7436
|
};
|
|
7437
|
+
// Per-recall savings: never less than the read-vs-source estimate (prior behavior),
|
|
7438
|
+
// raised to the knowledge-replay value when served packets carry discovery_tokens.
|
|
7439
|
+
const readVsSourceTokens = scored.length ? recallTokensSaved(projectDir, result.results, result.context_block) : 0;
|
|
7440
|
+
const replayTokens = scored.length ? replayTokensSaved(scored.map((entry) => entry.packet), result.context_block) : 0;
|
|
7270
7441
|
result.value_receipt = {
|
|
7271
|
-
tokens_saved:
|
|
7442
|
+
tokens_saved: Math.max(readVsSourceTokens, replayTokens),
|
|
7272
7443
|
stale_withheld: suppressed.length,
|
|
7444
|
+
...(replayTokens > 0 ? { replay_tokens: replayTokens } : {}),
|
|
7273
7445
|
};
|
|
7274
7446
|
if (inputs.trackAccess !== false) {
|
|
7275
7447
|
recordRecallAccess(projectDir, result.results);
|
|
7276
7448
|
recordValueEvents(projectDir, [
|
|
7277
7449
|
...suppressed.map((entry) => ({ kind: "stale_withheld", packet_title: entry.title })),
|
|
7278
|
-
...(scored.length ? [{ kind: "recall_served", tokens_saved: result.value_receipt.tokens_saved }] : []),
|
|
7450
|
+
...(scored.length ? [{ kind: "recall_served", tokens_saved: result.value_receipt.tokens_saved, replay_tokens: replayTokens }] : []),
|
|
7279
7451
|
]);
|
|
7280
7452
|
}
|
|
7281
7453
|
return result;
|
|
@@ -8717,6 +8889,276 @@ function truthReport(projectDir) {
|
|
|
8717
8889
|
],
|
|
8718
8890
|
};
|
|
8719
8891
|
}
|
|
8892
|
+
function defaultClaudeMemStorePath() {
|
|
8893
|
+
const dataDir = process.env.CLAUDE_MEM_DATA_DIR || (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude-mem");
|
|
8894
|
+
return (0, node_path_1.join)(dataDir, "claude-mem.db");
|
|
8895
|
+
}
|
|
8896
|
+
function claudeMemProjectKey(projectDir) {
|
|
8897
|
+
// claude-mem derives the project name from the git repo root basename so it
|
|
8898
|
+
// stays stable across subdirectories and worktrees; mirror that here.
|
|
8899
|
+
const repoRoot = readGit(projectDir, ["rev-parse", "--show-toplevel"]);
|
|
8900
|
+
return (0, node_path_1.basename)(repoRoot || (0, node_path_1.resolve)(projectDir));
|
|
8901
|
+
}
|
|
8902
|
+
// Mirrors claude-mem's own lenient parseFileList: JSON array of strings, with
|
|
8903
|
+
// non-JSON values treated as a single raw path.
|
|
8904
|
+
function parseClaudeMemFileList(value) {
|
|
8905
|
+
if (!value)
|
|
8906
|
+
return [];
|
|
8907
|
+
let parsed;
|
|
8908
|
+
try {
|
|
8909
|
+
parsed = JSON.parse(value);
|
|
8910
|
+
}
|
|
8911
|
+
catch {
|
|
8912
|
+
parsed = [value];
|
|
8913
|
+
}
|
|
8914
|
+
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
8915
|
+
return list
|
|
8916
|
+
.map((item) => String(item).trim())
|
|
8917
|
+
.filter((item) => item.length > 0 && !/^\[.*\]$/.test(item));
|
|
8918
|
+
}
|
|
8919
|
+
// Read-only SELECT against a claude-mem store. Prefers the built-in node:sqlite
|
|
8920
|
+
// module (Node 22+); falls back to the sqlite3 CLI with -json output. The
|
|
8921
|
+
// project key is inlined with quote-escaping for the CLI path because the CLI
|
|
8922
|
+
// has no parameter binding.
|
|
8923
|
+
function queryClaudeMemStore(storePath, sqlFor, projectKey) {
|
|
8924
|
+
let nodeSqliteError = null;
|
|
8925
|
+
try {
|
|
8926
|
+
// Guarded require: node:sqlite only exists on Node 22.5+, and this package
|
|
8927
|
+
// supports Node 18+. Compiled output is CJS, so require is available.
|
|
8928
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
8929
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
8930
|
+
const db = new DatabaseSync(storePath, { readOnly: true });
|
|
8931
|
+
try {
|
|
8932
|
+
const statement = db.prepare(sqlFor("?"));
|
|
8933
|
+
const rows = (projectKey === null ? statement.all() : statement.all(projectKey));
|
|
8934
|
+
return { ok: true, mechanism: "node:sqlite", rows };
|
|
8935
|
+
}
|
|
8936
|
+
finally {
|
|
8937
|
+
db.close();
|
|
8938
|
+
}
|
|
8939
|
+
}
|
|
8940
|
+
catch (error) {
|
|
8941
|
+
nodeSqliteError = error instanceof Error ? error.message : String(error);
|
|
8942
|
+
}
|
|
8943
|
+
try {
|
|
8944
|
+
const escaped = projectKey === null ? "''" : `'${projectKey.replace(/'/g, "''")}'`;
|
|
8945
|
+
const out = (0, node_child_process_1.execFileSync)("sqlite3", ["-readonly", "-json", storePath, sqlFor(escaped)], {
|
|
8946
|
+
encoding: "utf8",
|
|
8947
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
8948
|
+
});
|
|
8949
|
+
const trimmed = out.trim();
|
|
8950
|
+
return { ok: true, mechanism: "sqlite3-cli", rows: trimmed ? JSON.parse(trimmed) : [] };
|
|
8951
|
+
}
|
|
8952
|
+
catch {
|
|
8953
|
+
return {
|
|
8954
|
+
ok: false,
|
|
8955
|
+
error: [
|
|
8956
|
+
`Cannot read the claude-mem store at ${storePath}.`,
|
|
8957
|
+
"Kage needs one of:",
|
|
8958
|
+
" - Node 22+ (ships the built-in node:sqlite module), or",
|
|
8959
|
+
" - the `sqlite3` command-line tool on PATH (with -json support, sqlite 3.33+).",
|
|
8960
|
+
`node:sqlite said: ${nodeSqliteError ?? "unavailable"}`,
|
|
8961
|
+
"The store is opened read-only either way — Kage never writes to it.",
|
|
8962
|
+
].join("\n"),
|
|
8963
|
+
};
|
|
8964
|
+
}
|
|
8965
|
+
}
|
|
8966
|
+
function readClaudeMemObservations(storePath, projectKey) {
|
|
8967
|
+
if (!(0, node_fs_1.existsSync)(storePath)) {
|
|
8968
|
+
return {
|
|
8969
|
+
ok: false,
|
|
8970
|
+
error: `No claude-mem store found at ${storePath}. Pass --store <path> if yours lives elsewhere (claude-mem default: ~/.claude-mem/claude-mem.db, or $CLAUDE_MEM_DATA_DIR/claude-mem.db).`,
|
|
8971
|
+
};
|
|
8972
|
+
}
|
|
8973
|
+
const result = queryClaudeMemStore(storePath, (key) => `SELECT id, project, type, title, subtitle, files_read, files_modified, created_at, created_at_epoch FROM observations WHERE project = ${key} ORDER BY created_at_epoch ASC`, projectKey);
|
|
8974
|
+
if (!result.ok)
|
|
8975
|
+
return result;
|
|
8976
|
+
return { ok: true, mechanism: result.mechanism, rows: result.rows };
|
|
8977
|
+
}
|
|
8978
|
+
function readClaudeMemProjectNames(storePath) {
|
|
8979
|
+
const result = queryClaudeMemStore(storePath, () => "SELECT DISTINCT project FROM observations ORDER BY project", null);
|
|
8980
|
+
if (!result.ok)
|
|
8981
|
+
return [];
|
|
8982
|
+
return result.rows.map((row) => String(row.project ?? "")).filter(Boolean);
|
|
8983
|
+
}
|
|
8984
|
+
// claude-mem stores created_at_epoch in milliseconds; tolerate seconds too.
|
|
8985
|
+
function claudeMemObservationEpochSeconds(row) {
|
|
8986
|
+
const epoch = Number(row.created_at_epoch);
|
|
8987
|
+
if (Number.isFinite(epoch) && epoch > 0)
|
|
8988
|
+
return epoch > 1e12 ? Math.floor(epoch / 1000) : Math.floor(epoch);
|
|
8989
|
+
if (row.created_at) {
|
|
8990
|
+
const parsed = Date.parse(row.created_at);
|
|
8991
|
+
if (Number.isFinite(parsed))
|
|
8992
|
+
return Math.floor(parsed / 1000);
|
|
8993
|
+
}
|
|
8994
|
+
return null;
|
|
8995
|
+
}
|
|
8996
|
+
// One git pass over the whole history (same approach as truthReport): the
|
|
8997
|
+
// newest commit touching each path is its last-change signal. mtime is the
|
|
8998
|
+
// fallback for untracked or out-of-repo paths — fresh checkouts reset mtimes,
|
|
8999
|
+
// so git wins whenever it knows the path.
|
|
9000
|
+
function buildClaudeMemChangeSignal(projectDir) {
|
|
9001
|
+
const absProject = (0, node_path_1.resolve)(projectDir);
|
|
9002
|
+
const newestEpochByPath = new Map();
|
|
9003
|
+
if (gitHead(projectDir)) {
|
|
9004
|
+
const raw = readGit(projectDir, ["log", "--no-renames", "--format=__KAGE_CM__%x1f%ct", "--name-only"]) ?? "";
|
|
9005
|
+
const projectPrefix = gitProjectPrefix(projectDir) ?? "";
|
|
9006
|
+
let epoch = 0;
|
|
9007
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
9008
|
+
const line = rawLine.trim();
|
|
9009
|
+
if (!line)
|
|
9010
|
+
continue;
|
|
9011
|
+
if (line.startsWith("__KAGE_CM__")) {
|
|
9012
|
+
epoch = Number(line.split("\x1f")[1] ?? 0) || 0;
|
|
9013
|
+
continue;
|
|
9014
|
+
}
|
|
9015
|
+
const normalized = line.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
9016
|
+
const path = projectPrefix && normalized.startsWith(`${projectPrefix}/`) ? normalized.slice(projectPrefix.length + 1) : normalized;
|
|
9017
|
+
// git log is newest-first: first sighting is the newest commit for the path.
|
|
9018
|
+
if (!newestEpochByPath.has(path))
|
|
9019
|
+
newestEpochByPath.set(path, epoch);
|
|
9020
|
+
}
|
|
9021
|
+
}
|
|
9022
|
+
const resolveCited = (citedPath) => {
|
|
9023
|
+
const cleaned = citedPath.replace(/\\/g, "/");
|
|
9024
|
+
if (/^(?:[A-Za-z]:)?\//.test(cleaned) || cleaned.startsWith("~/")) {
|
|
9025
|
+
const abs = (0, node_path_1.resolve)(cleaned.startsWith("~/") ? (0, node_path_1.join)((0, node_os_1.homedir)(), cleaned.slice(2)) : cleaned);
|
|
9026
|
+
const rel = abs === absProject ? "" : abs.startsWith(absProject + node_path_1.sep) ? (0, node_path_1.relative)(absProject, abs).replace(/\\/g, "/") : null;
|
|
9027
|
+
return { abs, rel };
|
|
9028
|
+
}
|
|
9029
|
+
const rel = cleaned.replace(/^\.\//, "");
|
|
9030
|
+
return { abs: (0, node_path_1.join)(absProject, rel), rel };
|
|
9031
|
+
};
|
|
9032
|
+
return {
|
|
9033
|
+
exists: (citedPath) => (0, node_fs_1.existsSync)(resolveCited(citedPath).abs),
|
|
9034
|
+
lastChangedEpoch: (citedPath) => {
|
|
9035
|
+
const { abs, rel } = resolveCited(citedPath);
|
|
9036
|
+
if (rel !== null) {
|
|
9037
|
+
const fromGit = newestEpochByPath.get(rel);
|
|
9038
|
+
if (fromGit !== undefined)
|
|
9039
|
+
return fromGit;
|
|
9040
|
+
}
|
|
9041
|
+
const stat = safeStat(abs);
|
|
9042
|
+
return stat ? Math.floor(stat.mtimeMs / 1000) : null;
|
|
9043
|
+
},
|
|
9044
|
+
};
|
|
9045
|
+
}
|
|
9046
|
+
// Pure classifier over parsed rows: VERIFIED (all cited paths exist and are
|
|
9047
|
+
// unchanged since capture), DRIFTED (a cited path changed after capture), GONE
|
|
9048
|
+
// (a cited path no longer exists), UNCITED (no file citations at all).
|
|
9049
|
+
// GONE outranks DRIFTED outranks VERIFIED when citations disagree.
|
|
9050
|
+
function classifyClaudeMemObservations(rows, signal, nowEpochSeconds = Math.floor(Date.now() / 1000)) {
|
|
9051
|
+
return rows.map((row) => {
|
|
9052
|
+
const cited = unique([...parseClaudeMemFileList(row.files_read), ...parseClaudeMemFileList(row.files_modified)]);
|
|
9053
|
+
const obsEpoch = claudeMemObservationEpochSeconds(row);
|
|
9054
|
+
const ageDays = obsEpoch === null ? null : Math.max(0, Math.floor((nowEpochSeconds - obsEpoch) / 86400));
|
|
9055
|
+
const title = (row.title ?? "").trim() || (row.subtitle ?? "").trim() || `observation #${row.id}`;
|
|
9056
|
+
if (!cited.length) {
|
|
9057
|
+
return { id: row.id, type: row.type ?? null, title, status: "uncited", created_at: row.created_at ?? null, age_days: ageDays, citations: [] };
|
|
9058
|
+
}
|
|
9059
|
+
const citations = cited.map((path) => {
|
|
9060
|
+
if (!signal.exists(path))
|
|
9061
|
+
return { path, status: "gone", changed_at: null };
|
|
9062
|
+
const changedEpoch = signal.lastChangedEpoch(path);
|
|
9063
|
+
if (changedEpoch !== null && obsEpoch !== null && changedEpoch > obsEpoch) {
|
|
9064
|
+
return { path, status: "drifted", changed_at: new Date(changedEpoch * 1000).toISOString() };
|
|
9065
|
+
}
|
|
9066
|
+
return { path, status: "verified", changed_at: null };
|
|
9067
|
+
});
|
|
9068
|
+
const status = citations.some((c) => c.status === "gone")
|
|
9069
|
+
? "gone"
|
|
9070
|
+
: citations.some((c) => c.status === "drifted")
|
|
9071
|
+
? "drifted"
|
|
9072
|
+
: "verified";
|
|
9073
|
+
return { id: row.id, type: row.type ?? null, title, status, created_at: row.created_at ?? null, age_days: ageDays, citations };
|
|
9074
|
+
});
|
|
9075
|
+
}
|
|
9076
|
+
function auditClaudeMemStore(projectDir, options = {}) {
|
|
9077
|
+
const storePath = options.storePath ?? defaultClaudeMemStorePath();
|
|
9078
|
+
const projectKey = claudeMemProjectKey(projectDir);
|
|
9079
|
+
const read = readClaudeMemObservations(storePath, projectKey);
|
|
9080
|
+
if (!read.ok)
|
|
9081
|
+
return { ok: false, error: read.error };
|
|
9082
|
+
const warnings = [];
|
|
9083
|
+
if (!read.rows.length) {
|
|
9084
|
+
const others = readClaudeMemProjectNames(storePath);
|
|
9085
|
+
warnings.push(others.length
|
|
9086
|
+
? `No observations for project "${projectKey}". The store has: ${others.slice(0, 12).join(", ")}${others.length > 12 ? ", …" : ""}. Run from the matching directory or pass --project.`
|
|
9087
|
+
: "The store has no observations at all.");
|
|
9088
|
+
}
|
|
9089
|
+
if (!gitHead(projectDir)) {
|
|
9090
|
+
warnings.push("Git history is unavailable for this project; change detection falls back to file mtimes, which fresh checkouts reset.");
|
|
9091
|
+
}
|
|
9092
|
+
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
9093
|
+
const entries = classifyClaudeMemObservations(read.rows, buildClaudeMemChangeSignal(projectDir), nowEpoch);
|
|
9094
|
+
const count = (status) => entries.filter((entry) => entry.status === status).length;
|
|
9095
|
+
const epochs = read.rows.map((row) => claudeMemObservationEpochSeconds(row)).filter((epoch) => epoch !== null);
|
|
9096
|
+
const spanDays = epochs.length ? Math.max(1, Math.ceil((Math.max(...epochs) - Math.min(...epochs)) / 86400)) : 0;
|
|
9097
|
+
const worstOffenders = entries
|
|
9098
|
+
.filter((entry) => entry.status === "drifted" || entry.status === "gone")
|
|
9099
|
+
.sort((a, b) => (b.age_days ?? 0) - (a.age_days ?? 0) || a.id - b.id)
|
|
9100
|
+
.slice(0, 5)
|
|
9101
|
+
.map((entry) => {
|
|
9102
|
+
const offending = entry.citations.find((c) => c.status === entry.status) ?? entry.citations[0];
|
|
9103
|
+
const whatChanged = offending.status === "gone"
|
|
9104
|
+
? "file no longer exists"
|
|
9105
|
+
: `changed ${offending.changed_at?.slice(0, 10) ?? "after capture"}${entry.created_at ? ` (captured ${entry.created_at.slice(0, 10)})` : ""}`;
|
|
9106
|
+
return { id: entry.id, title: entry.title, status: entry.status, age_days: entry.age_days, path: offending.path, what_changed: whatChanged };
|
|
9107
|
+
});
|
|
9108
|
+
return {
|
|
9109
|
+
ok: true,
|
|
9110
|
+
report: {
|
|
9111
|
+
schema_version: 1,
|
|
9112
|
+
store_path: storePath,
|
|
9113
|
+
project_dir: projectDir,
|
|
9114
|
+
project_key: projectKey,
|
|
9115
|
+
generated_at: nowIso(),
|
|
9116
|
+
reader: read.mechanism,
|
|
9117
|
+
totals: {
|
|
9118
|
+
observations: entries.length,
|
|
9119
|
+
verified: count("verified"),
|
|
9120
|
+
drifted: count("drifted"),
|
|
9121
|
+
gone: count("gone"),
|
|
9122
|
+
uncited: count("uncited"),
|
|
9123
|
+
},
|
|
9124
|
+
span_days: spanDays,
|
|
9125
|
+
worst_offenders: worstOffenders,
|
|
9126
|
+
observations: entries,
|
|
9127
|
+
warnings,
|
|
9128
|
+
},
|
|
9129
|
+
};
|
|
9130
|
+
}
|
|
9131
|
+
function renderClaudeMemAuditReceipt(report) {
|
|
9132
|
+
const lines = [];
|
|
9133
|
+
lines.push(`Kage audit — claude-mem store for ${report.project_key}`);
|
|
9134
|
+
const total = report.totals.observations;
|
|
9135
|
+
lines.push(`${total} observation${total === 1 ? "" : "s"} · captured over ${report.span_days} day${report.span_days === 1 ? "" : "s"}`);
|
|
9136
|
+
if (total > 0) {
|
|
9137
|
+
const pct = (n) => `${Math.round((n / total) * 100)}%`;
|
|
9138
|
+
const row = (label, n, note) => `■ ${label.padEnd(10)} ${String(n).padStart(3)} (${pct(n)}) ${note}`;
|
|
9139
|
+
lines.push(row("VERIFIED", report.totals.verified, "still match your code"));
|
|
9140
|
+
lines.push(row("DRIFTED", report.totals.drifted, "cite files that changed since capture — may be stale"));
|
|
9141
|
+
lines.push(row("GONE", report.totals.gone, "cite files that no longer exist"));
|
|
9142
|
+
lines.push(row("UNCITED", report.totals.uncited, "no file citations — unverifiable by construction"));
|
|
9143
|
+
}
|
|
9144
|
+
if (report.worst_offenders.length) {
|
|
9145
|
+
lines.push("");
|
|
9146
|
+
lines.push("Worst offenders:");
|
|
9147
|
+
for (const offender of report.worst_offenders) {
|
|
9148
|
+
lines.push(` • "${offender.title}" — ${offender.status.toUpperCase()}${offender.age_days !== null ? `, ${offender.age_days}d old` : ""}`);
|
|
9149
|
+
lines.push(` ${offender.path} — ${offender.what_changed}`);
|
|
9150
|
+
}
|
|
9151
|
+
}
|
|
9152
|
+
if (report.warnings.length) {
|
|
9153
|
+
lines.push("");
|
|
9154
|
+
for (const warning of report.warnings)
|
|
9155
|
+
lines.push(`Warning: ${warning}`);
|
|
9156
|
+
}
|
|
9157
|
+
lines.push("");
|
|
9158
|
+
lines.push("claude-mem remembers everything. Kage tells you what's still true.");
|
|
9159
|
+
lines.push("Import coming soon — https://kage-core.github.io/Kage/");
|
|
9160
|
+
return lines.join("\n");
|
|
9161
|
+
}
|
|
8720
9162
|
function kageReviewerSuggestions(projectDir, targets = [], changedFiles = []) {
|
|
8721
9163
|
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
8722
9164
|
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
@@ -11997,6 +12439,20 @@ function learn(input) {
|
|
|
11997
12439
|
input.evidence ? `\nEvidence: ${input.evidence.trim()}` : "",
|
|
11998
12440
|
input.verifiedBy ? `\nVerified by: ${input.verifiedBy.trim()}` : "",
|
|
11999
12441
|
].join("").trim();
|
|
12442
|
+
// Strict (agent/CLI) repo learnings must be grounded: a learning with no cited
|
|
12443
|
+
// paths at all is rejected. Citation-free notes are allowed only in the
|
|
12444
|
+
// personal store (`kage learn --personal` / learnPersonal), where recall
|
|
12445
|
+
// labels them unverifiable instead of trusting them as repo facts.
|
|
12446
|
+
if (input.strictCitations && !(input.paths ?? []).filter(Boolean).length && !input.allowMissingPaths) {
|
|
12447
|
+
return {
|
|
12448
|
+
ok: false,
|
|
12449
|
+
errors: [
|
|
12450
|
+
"Citation required: repo learnings must cite at least one path (--paths) so the memory stays verifiable. " +
|
|
12451
|
+
"Pass allow_missing_paths for a file you are about to create, or use kage learn --personal for a cross-repo, citation-free personal note.",
|
|
12452
|
+
],
|
|
12453
|
+
warnings: [],
|
|
12454
|
+
};
|
|
12455
|
+
}
|
|
12000
12456
|
return capture({
|
|
12001
12457
|
projectDir: input.projectDir,
|
|
12002
12458
|
title,
|
|
@@ -12011,6 +12467,7 @@ function learn(input) {
|
|
|
12011
12467
|
strictCitations: input.strictCitations,
|
|
12012
12468
|
graphNodes: input.graphNodes,
|
|
12013
12469
|
pendingReview: input.pendingReview,
|
|
12470
|
+
discoveryTokens: input.discoveryTokens,
|
|
12014
12471
|
});
|
|
12015
12472
|
}
|
|
12016
12473
|
function capture(input) {
|
|
@@ -12104,6 +12561,12 @@ function capture(input) {
|
|
|
12104
12561
|
reports_stale: 0,
|
|
12105
12562
|
review_boundary: "git_or_pr",
|
|
12106
12563
|
promotion_requires_review: true,
|
|
12564
|
+
// Discovery cost receipt: what producing this knowledge cost (exploration +
|
|
12565
|
+
// reasoning tokens). Caller-reported when available; otherwise a conservative
|
|
12566
|
+
// per-type default, flagged estimated so receipts can qualify the claim.
|
|
12567
|
+
...(Number.isFinite(Number(input.discoveryTokens)) && Number(input.discoveryTokens) > 0
|
|
12568
|
+
? { discovery_tokens: Math.floor(Number(input.discoveryTokens)), discovery_tokens_estimated: false }
|
|
12569
|
+
: { discovery_tokens: defaultDiscoveryTokens(type), discovery_tokens_estimated: true }),
|
|
12107
12570
|
},
|
|
12108
12571
|
created_at: createdAt,
|
|
12109
12572
|
updated_at: createdAt,
|
|
@@ -12531,6 +12994,71 @@ print(json.dumps({"additionalContext": os.environ.get("KAGE_CONTEXT", "")}))
|
|
|
12531
12994
|
fi
|
|
12532
12995
|
fi
|
|
12533
12996
|
|
|
12997
|
+
exit 0
|
|
12998
|
+
`;
|
|
12999
|
+
const readContextHookScript = `#!/usr/bin/env bash
|
|
13000
|
+
# Kage PreToolUse(Read) hook — injects verified file-linked memory right before the agent reads a file.
|
|
13001
|
+
# Only currently-verified packets (citations checked, not stale) are ever injected.
|
|
13002
|
+
# Silent if Kage is not initialized in the current project. Never blocks the Read.
|
|
13003
|
+
set -euo pipefail
|
|
13004
|
+
|
|
13005
|
+
PAYLOAD="$(cat || true)"
|
|
13006
|
+
CWD="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
13007
|
+
try:
|
|
13008
|
+
d = json.loads(os.environ.get("PAYLOAD") or "{}")
|
|
13009
|
+
except Exception:
|
|
13010
|
+
d = {}
|
|
13011
|
+
print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
|
|
13012
|
+
' 2>/dev/null || echo "")"
|
|
13013
|
+
|
|
13014
|
+
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
13015
|
+
command -v kage >/dev/null 2>&1 || exit 0
|
|
13016
|
+
|
|
13017
|
+
FILE_PATH="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
13018
|
+
try:
|
|
13019
|
+
d = json.loads(os.environ.get("PAYLOAD") or "{}")
|
|
13020
|
+
except Exception:
|
|
13021
|
+
d = {}
|
|
13022
|
+
tool_input = d.get("tool_input") or d.get("toolInput") or {}
|
|
13023
|
+
path = tool_input.get("file_path") if isinstance(tool_input, dict) else ""
|
|
13024
|
+
print(path or "")
|
|
13025
|
+
' 2>/dev/null || echo "")"
|
|
13026
|
+
[[ -n "$FILE_PATH" ]] || exit 0
|
|
13027
|
+
[[ "$FILE_PATH" = /* ]] || FILE_PATH="$CWD/$FILE_PATH"
|
|
13028
|
+
|
|
13029
|
+
# Skip files outside the project: memory is repo-scoped.
|
|
13030
|
+
case "$FILE_PATH" in
|
|
13031
|
+
"$CWD"/*) ;;
|
|
13032
|
+
*) exit 0 ;;
|
|
13033
|
+
esac
|
|
13034
|
+
|
|
13035
|
+
SESSION="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
13036
|
+
try:
|
|
13037
|
+
d = json.loads(os.environ.get("PAYLOAD") or "{}")
|
|
13038
|
+
except Exception:
|
|
13039
|
+
d = {}
|
|
13040
|
+
print(d.get("session_id") or d.get("sessionId") or "default")
|
|
13041
|
+
' 2>/dev/null || echo "default")"
|
|
13042
|
+
|
|
13043
|
+
# Dedup: inject at most once per file per session via a tiny /tmp state file
|
|
13044
|
+
# keyed by session_id+path. Failure to track must never block the Read.
|
|
13045
|
+
STATE_DIR="/tmp/kage-read-context"
|
|
13046
|
+
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
|
13047
|
+
KEY="$(printf "%s|%s" "$SESSION" "$FILE_PATH" | python3 -c 'import hashlib, sys
|
|
13048
|
+
print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:24])
|
|
13049
|
+
' 2>/dev/null || echo "")"
|
|
13050
|
+
if [[ -n "$KEY" && -d "$STATE_DIR" ]]; then
|
|
13051
|
+
[[ -e "$STATE_DIR/$KEY" ]] && exit 0
|
|
13052
|
+
: > "$STATE_DIR/$KEY" 2>/dev/null || true
|
|
13053
|
+
fi
|
|
13054
|
+
|
|
13055
|
+
CONTEXT="$(kage file-context --project "$CWD" --path "$FILE_PATH" 2>/dev/null || true)"
|
|
13056
|
+
if [[ -n "$CONTEXT" ]]; then
|
|
13057
|
+
KAGE_CONTEXT="$CONTEXT" python3 -c 'import json, os
|
|
13058
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": os.environ.get("KAGE_CONTEXT", "")}}))
|
|
13059
|
+
' 2>/dev/null || true
|
|
13060
|
+
fi
|
|
13061
|
+
|
|
12534
13062
|
exit 0
|
|
12535
13063
|
`;
|
|
12536
13064
|
const settingsPath = (0, node_path_1.join)(home, ".claude", "settings.json");
|
|
@@ -12538,7 +13066,11 @@ exit 0
|
|
|
12538
13066
|
hooks: {
|
|
12539
13067
|
SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/session-start.sh", timeout: 5 }] }],
|
|
12540
13068
|
UserPromptSubmit: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 12 }] }],
|
|
12541
|
-
PreToolUse: [
|
|
13069
|
+
PreToolUse: [
|
|
13070
|
+
{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] },
|
|
13071
|
+
// Verified memory at the moment of relevance: short timeout, never blocks the Read.
|
|
13072
|
+
{ matcher: "Read", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/kage-read-context.sh", timeout: 6 }] },
|
|
13073
|
+
],
|
|
12542
13074
|
PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
12543
13075
|
PostToolUseFailure: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
12544
13076
|
PreCompact: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
|
|
@@ -12550,7 +13082,7 @@ exit 0
|
|
|
12550
13082
|
setSnippet(path, JSON.stringify({ mcpServers: { kage: server } }, null, 2), [
|
|
12551
13083
|
"Add the MCP server to ~/.claude.json, then restart Claude Code.",
|
|
12552
13084
|
"alwaysLoad: true makes Kage tools immediately visible without requiring ToolSearch.",
|
|
12553
|
-
`Also create ${hookDir}/session-start.sh, observe.sh, and stop.sh with the hook scripts and add SessionStart/UserPromptSubmit/PostToolUse/PostToolUseFailure/PreCompact/Stop/SessionEnd hooks to ~/.claude/settings.json.`,
|
|
13085
|
+
`Also create ${hookDir}/session-start.sh, observe.sh, kage-read-context.sh, and stop.sh with the hook scripts and add SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/PostToolUseFailure/PreCompact/Stop/SessionEnd hooks to ~/.claude/settings.json.`,
|
|
12554
13086
|
"Run `kage init --project <repo>` inside each repo to install the ambient memory policy.",
|
|
12555
13087
|
], true);
|
|
12556
13088
|
if (options.write) {
|
|
@@ -12559,6 +13091,7 @@ exit 0
|
|
|
12559
13091
|
(0, node_fs_1.mkdirSync)(hookDir, { recursive: true });
|
|
12560
13092
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "session-start.sh"), hookScript, { mode: 0o755 });
|
|
12561
13093
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "observe.sh"), observeHookScript, { mode: 0o755 });
|
|
13094
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "kage-read-context.sh"), readContextHookScript, { mode: 0o755 });
|
|
12562
13095
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "stop.sh"), stopHookScript, { mode: 0o755 });
|
|
12563
13096
|
upsertJsonSettings(settingsPath, hookEntry);
|
|
12564
13097
|
result.wrote = true;
|
|
@@ -12699,7 +13232,7 @@ function claudeHookEventConfigured(settings, event) {
|
|
|
12699
13232
|
function claudeAmbientHookSummary(homeDir) {
|
|
12700
13233
|
const settingsPath = (0, node_path_1.join)(homeDir, ".claude", "settings.json");
|
|
12701
13234
|
const hookDir = (0, node_path_1.join)(homeDir, ".claude", "kage", "hooks");
|
|
12702
|
-
const scriptPaths = [(0, node_path_1.join)(hookDir, "session-start.sh"), (0, node_path_1.join)(hookDir, "observe.sh"), (0, node_path_1.join)(hookDir, "stop.sh")];
|
|
13235
|
+
const scriptPaths = [(0, node_path_1.join)(hookDir, "session-start.sh"), (0, node_path_1.join)(hookDir, "observe.sh"), (0, node_path_1.join)(hookDir, "kage-read-context.sh"), (0, node_path_1.join)(hookDir, "stop.sh")];
|
|
12703
13236
|
let settings = {};
|
|
12704
13237
|
if ((0, node_fs_1.existsSync)(settingsPath)) {
|
|
12705
13238
|
const parsed = readJson(settingsPath);
|
|
@@ -12835,6 +13368,9 @@ function observe(projectDir, event) {
|
|
|
12835
13368
|
if ((0, node_fs_1.existsSync)(path))
|
|
12836
13369
|
return { ok: true, stored: false, duplicate: true, path, errors: [] };
|
|
12837
13370
|
const timestamp = event.timestamp ? new Date(event.timestamp).toISOString() : nowIso();
|
|
13371
|
+
// Tag low-signal events at ingestion (manual learn/capture is never gated, but
|
|
13372
|
+
// auto-distill skips tagged events cheaply without rescoring).
|
|
13373
|
+
const lowSignal = observationSignalScore(event) < exports.AUTO_DISTILL_SIGNAL_THRESHOLD;
|
|
12838
13374
|
const record = {
|
|
12839
13375
|
...event,
|
|
12840
13376
|
schema_version: 1,
|
|
@@ -12844,6 +13380,7 @@ function observe(projectDir, event) {
|
|
|
12844
13380
|
session_id: event.session_id || "default",
|
|
12845
13381
|
timestamp,
|
|
12846
13382
|
stored_at: nowIso(),
|
|
13383
|
+
...(lowSignal ? { low_signal: true } : {}),
|
|
12847
13384
|
};
|
|
12848
13385
|
writeJson(path, record);
|
|
12849
13386
|
return { ok: true, stored: true, duplicate: false, record, path, errors: [] };
|
|
@@ -12855,6 +13392,143 @@ function loadObservations(projectDir, sessionId) {
|
|
|
12855
13392
|
.filter((record) => !sessionId || record.session_id === sessionId)
|
|
12856
13393
|
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
12857
13394
|
}
|
|
13395
|
+
// Auto-distill quality gate. Observations must score at least this (0..1) on
|
|
13396
|
+
// observationSignalScore before they may seed an auto-distilled draft. 0.4 was picked
|
|
13397
|
+
// so genuine learnings clear it comfortably (causal prose plus a path, command, or
|
|
13398
|
+
// code identifier lands around 0.45-0.65) while machine noise hard-rejects to 0:
|
|
13399
|
+
// raw JSON payloads, hook/system envelopes, flag-token dumps, sub-50-char fragments,
|
|
13400
|
+
// and echoes of Kage's own demo/receipt output. Manual `kage learn`/`kage capture`
|
|
13401
|
+
// and manual `kage distill` are never gated — explicit intent outranks the heuristic.
|
|
13402
|
+
exports.AUTO_DISTILL_SIGNAL_THRESHOLD = 0.4;
|
|
13403
|
+
// Markers of hook/system plumbing payloads that sometimes leak into observation text
|
|
13404
|
+
// (e.g. a raw <task-notification> block stored as a "user prompt").
|
|
13405
|
+
const HOOK_PAYLOAD_MARKERS = [
|
|
13406
|
+
"task-notification",
|
|
13407
|
+
"task_notification",
|
|
13408
|
+
"tool-use-id",
|
|
13409
|
+
"tool_use_id",
|
|
13410
|
+
"toolu_",
|
|
13411
|
+
"system-reminder",
|
|
13412
|
+
"system_reminder",
|
|
13413
|
+
"hookspecificoutput",
|
|
13414
|
+
"hook_event_name",
|
|
13415
|
+
"stop_hook_active",
|
|
13416
|
+
"sessionstart hook",
|
|
13417
|
+
];
|
|
13418
|
+
// Markers of Kage's own output being echoed back as "memory" (truth-report headers,
|
|
13419
|
+
// demo proof lines, value-receipt fields). Storing our own output is feedback noise.
|
|
13420
|
+
const KAGE_ECHO_MARKERS = [
|
|
13421
|
+
"truth report",
|
|
13422
|
+
"hallucinated citations never enter storage",
|
|
13423
|
+
"hallucinated citation",
|
|
13424
|
+
"value_receipt",
|
|
13425
|
+
"stale_withheld",
|
|
13426
|
+
"# previously (kage)",
|
|
13427
|
+
];
|
|
13428
|
+
function jsonNoiseText(text) {
|
|
13429
|
+
const trimmed = text.trim();
|
|
13430
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
13431
|
+
try {
|
|
13432
|
+
JSON.parse(trimmed);
|
|
13433
|
+
return true;
|
|
13434
|
+
}
|
|
13435
|
+
catch {
|
|
13436
|
+
// Not parseable; fall through to the density heuristics.
|
|
13437
|
+
}
|
|
13438
|
+
}
|
|
13439
|
+
// Three or more quoted-key patterns reads as serialized data, not prose.
|
|
13440
|
+
if ((text.match(/"[\w$.-]+"\s*:/g) ?? []).length >= 3)
|
|
13441
|
+
return true;
|
|
13442
|
+
const dense = text.replace(/\s+/g, "");
|
|
13443
|
+
if (!dense.length)
|
|
13444
|
+
return true;
|
|
13445
|
+
const jsonPunctuation = (text.match(/[{}[\]":,]/g) ?? []).length;
|
|
13446
|
+
return jsonPunctuation / dense.length > 0.5;
|
|
13447
|
+
}
|
|
13448
|
+
function flagTokenNoise(lower) {
|
|
13449
|
+
// Dumps like "interrupted false isImage false noOutputExpected false" or
|
|
13450
|
+
// "x=false y=true": several key/boolean pairs making up a large share of the text.
|
|
13451
|
+
const pairs = (lower.match(/\b[a-z][\w-]*\s*[=:]?\s*(true|false|null|undefined)\b/g) ?? []).length;
|
|
13452
|
+
if (pairs < 2)
|
|
13453
|
+
return false;
|
|
13454
|
+
const words = lower.split(/\s+/).filter(Boolean).length;
|
|
13455
|
+
return words > 0 && (pairs * 2) / words >= 0.4;
|
|
13456
|
+
}
|
|
13457
|
+
// Imperative/causal vocabulary that marks durable, future-facing knowledge.
|
|
13458
|
+
const SIGNAL_CAUSAL_MARKERS = [
|
|
13459
|
+
"fixed",
|
|
13460
|
+
"because",
|
|
13461
|
+
"use ",
|
|
13462
|
+
"instead",
|
|
13463
|
+
"run ",
|
|
13464
|
+
"must",
|
|
13465
|
+
"should",
|
|
13466
|
+
"requires",
|
|
13467
|
+
"prefer",
|
|
13468
|
+
"avoid",
|
|
13469
|
+
"fail",
|
|
13470
|
+
"caused",
|
|
13471
|
+
"root cause",
|
|
13472
|
+
"why",
|
|
13473
|
+
"decision",
|
|
13474
|
+
"convention",
|
|
13475
|
+
"gotcha",
|
|
13476
|
+
"workaround",
|
|
13477
|
+
"hypothesis",
|
|
13478
|
+
"issue",
|
|
13479
|
+
"explains",
|
|
13480
|
+
"invariant",
|
|
13481
|
+
"maps ",
|
|
13482
|
+
"after changing",
|
|
13483
|
+
"when changing",
|
|
13484
|
+
"never",
|
|
13485
|
+
"always",
|
|
13486
|
+
];
|
|
13487
|
+
/**
|
|
13488
|
+
* Pure 0..1 signal score for an observation: how likely its text is durable,
|
|
13489
|
+
* human-meaningful repo knowledge rather than machine noise. Hard rejects (0):
|
|
13490
|
+
* raw JSON / key-value noise, hook or system payloads, echoes of Kage's own
|
|
13491
|
+
* output, flag-token dumps, and fragments under 50 chars. Positive signal:
|
|
13492
|
+
* imperative/causal language, file path citations, code identifiers, commands.
|
|
13493
|
+
*/
|
|
13494
|
+
function observationSignalScore(observation) {
|
|
13495
|
+
const prose = [observation.summary, observation.text].filter(Boolean).join("\n").trim();
|
|
13496
|
+
const combined = [prose, observation.command].filter(Boolean).join("\n").trim();
|
|
13497
|
+
if (combined.length < 50)
|
|
13498
|
+
return 0;
|
|
13499
|
+
const lower = combined.toLowerCase();
|
|
13500
|
+
if (HOOK_PAYLOAD_MARKERS.some((marker) => lower.includes(marker)))
|
|
13501
|
+
return 0;
|
|
13502
|
+
if (KAGE_ECHO_MARKERS.some((marker) => lower.includes(marker)))
|
|
13503
|
+
return 0;
|
|
13504
|
+
if (jsonNoiseText(combined))
|
|
13505
|
+
return 0;
|
|
13506
|
+
if (flagTokenNoise(lower))
|
|
13507
|
+
return 0;
|
|
13508
|
+
let score = 0;
|
|
13509
|
+
const causalHits = SIGNAL_CAUSAL_MARKERS.filter((marker) => lower.includes(marker)).length;
|
|
13510
|
+
if (causalHits > 0)
|
|
13511
|
+
score += Math.min(0.35, 0.25 + (causalHits - 1) * 0.05);
|
|
13512
|
+
const citesPath = Boolean(observation.path)
|
|
13513
|
+
|| /\b[\w.-]+\/[\w./-]+\b/.test(prose)
|
|
13514
|
+
|| /\b[\w-]+\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|json|ya?ml|toml|md|sh|css|html|sql)\b/i.test(prose);
|
|
13515
|
+
if (citesPath)
|
|
13516
|
+
score += 0.2;
|
|
13517
|
+
const codeIdentifier = /\b[a-z][a-z0-9]*[A-Z]\w*\b/.test(prose) // camelCase
|
|
13518
|
+
|| /\b[a-z0-9]+_[a-z0-9_]+\b/.test(prose) // snake_case
|
|
13519
|
+
|| /`[^`]+`/.test(prose)
|
|
13520
|
+
|| /\b\w+\(\)/.test(prose);
|
|
13521
|
+
if (codeIdentifier)
|
|
13522
|
+
score += 0.15;
|
|
13523
|
+
const commandLine = Boolean(observation.command)
|
|
13524
|
+
|| /(^|\s)(npm|pnpm|yarn|npx|node|git|cargo|make|pytest|go|tsc|kage)\s+[\w.-]/.test(prose);
|
|
13525
|
+
if (commandLine)
|
|
13526
|
+
score += 0.15;
|
|
13527
|
+
const words = combined.split(/\s+/).filter(Boolean).length;
|
|
13528
|
+
if (combined.length >= 80 && words >= 10)
|
|
13529
|
+
score += 0.1;
|
|
13530
|
+
return Math.min(1, score);
|
|
13531
|
+
}
|
|
12858
13532
|
function reusableFileObservation(event) {
|
|
12859
13533
|
const text = `${event.summary ?? ""}\n${event.text ?? ""}`.trim();
|
|
12860
13534
|
if (!text)
|
|
@@ -13346,14 +14020,18 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
13346
14020
|
const mode = auto ? "auto" : "manual";
|
|
13347
14021
|
const observations = loadObservations(projectDir, sessionId);
|
|
13348
14022
|
if (auto && observations.length === 0) {
|
|
13349
|
-
return { ok: true, session_id: sessionId, observations: 0, candidates: [], errors: [], mode, skipped_reason: "no_observations" };
|
|
14023
|
+
return { ok: true, session_id: sessionId, observations: 0, candidates: [], errors: [], mode, skipped_reason: "no_observations", skipped_low_signal: 0 };
|
|
13350
14024
|
}
|
|
13351
14025
|
if (auto && sessionAlreadyCaptured(projectDir, sessionId, observations)) {
|
|
13352
|
-
return { ok: true, session_id: sessionId, observations: observations.length, candidates: [], errors: [], mode, skipped_reason: "session_already_captured" };
|
|
14026
|
+
return { ok: true, session_id: sessionId, observations: observations.length, candidates: [], errors: [], mode, skipped_reason: "session_already_captured", skipped_low_signal: 0 };
|
|
13353
14027
|
}
|
|
13354
14028
|
const candidates = [];
|
|
13355
14029
|
const errors = [];
|
|
13356
14030
|
const observationIds = observations.map((event) => event.id);
|
|
14031
|
+
// Discovery cost of distilled knowledge: the token estimate of the session material
|
|
14032
|
+
// that produced it. Still an estimate (the agent's reasoning tokens are unknown),
|
|
14033
|
+
// so it stays flagged discovery_tokens_estimated.
|
|
14034
|
+
const sessionDiscoveryTokens = observations.reduce((sum, event) => sum + estimateTokens([event.summary, event.text, event.command, event.path].filter(Boolean).join(" ")), 0);
|
|
13357
14035
|
const annotate = (result) => {
|
|
13358
14036
|
if (!result.ok || !result.packet || !result.path)
|
|
13359
14037
|
return result;
|
|
@@ -13367,6 +14045,9 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
13367
14045
|
];
|
|
13368
14046
|
result.packet.quality = {
|
|
13369
14047
|
...result.packet.quality,
|
|
14048
|
+
...(sessionDiscoveryTokens > 0
|
|
14049
|
+
? { discovery_tokens: sessionDiscoveryTokens, discovery_tokens_estimated: true }
|
|
14050
|
+
: {}),
|
|
13370
14051
|
distillation: auto ? "auto_distill" : "automatic_observation_candidate",
|
|
13371
14052
|
admission: evaluateMemoryAdmission(projectDir, result.packet),
|
|
13372
14053
|
suggested_review_action: suggestedAction(classifyPacket(projectDir, result.packet), result.packet.status),
|
|
@@ -13375,9 +14056,24 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
13375
14056
|
return result;
|
|
13376
14057
|
};
|
|
13377
14058
|
const autoTags = auto ? [exports.AUTO_DISTILL_TAG] : [];
|
|
13378
|
-
|
|
13379
|
-
|
|
13380
|
-
|
|
14059
|
+
// Auto-distill quality gate: drafts may only be seeded by observations scoring at
|
|
14060
|
+
// least AUTO_DISTILL_SIGNAL_THRESHOLD. Events tagged low_signal at ingestion skip
|
|
14061
|
+
// cheaply; untagged (older) records are scored here. Manual distill is not gated.
|
|
14062
|
+
let skippedLowSignal = 0;
|
|
14063
|
+
const signalGate = (events) => {
|
|
14064
|
+
if (!auto)
|
|
14065
|
+
return events;
|
|
14066
|
+
return events.filter((event) => {
|
|
14067
|
+
const lowSignal = event.low_signal === true
|
|
14068
|
+
|| (event.low_signal === undefined && observationSignalScore(event) < exports.AUTO_DISTILL_SIGNAL_THRESHOLD);
|
|
14069
|
+
if (lowSignal)
|
|
14070
|
+
skippedLowSignal += 1;
|
|
14071
|
+
return !lowSignal;
|
|
14072
|
+
});
|
|
14073
|
+
};
|
|
14074
|
+
const commandEvents = signalGate(observations.filter((event) => event.type === "command_result" && event.command));
|
|
14075
|
+
const fileEvents = signalGate(observations.filter((event) => event.type === "file_change" && event.path));
|
|
14076
|
+
const promptEvents = signalGate(observations.filter((event) => event.type === "user_prompt" && (event.text || event.summary)));
|
|
13381
14077
|
const meaningfulCommandEvents = commandEvents
|
|
13382
14078
|
.map((event) => ({ event, reusable: reusableCommandObservation(event, knownRepoCommands(projectDir)) }))
|
|
13383
14079
|
.filter((item) => Boolean(item.reusable));
|
|
@@ -13427,10 +14123,38 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
13427
14123
|
for (const result of candidates)
|
|
13428
14124
|
if (!result.ok)
|
|
13429
14125
|
errors.push(...result.errors);
|
|
13430
|
-
return {
|
|
14126
|
+
return {
|
|
14127
|
+
ok: errors.length === 0,
|
|
14128
|
+
session_id: sessionId,
|
|
14129
|
+
observations: observations.length,
|
|
14130
|
+
candidates,
|
|
14131
|
+
errors,
|
|
14132
|
+
mode,
|
|
14133
|
+
...(auto ? { skipped_low_signal: skippedLowSignal } : {}),
|
|
14134
|
+
};
|
|
13431
14135
|
}
|
|
13432
14136
|
// Session continuity: a compact "previously…" digest the SessionStart hook injects so a new
|
|
13433
14137
|
// session starts with last session's context instead of cold. Empty when there is no prior data.
|
|
14138
|
+
function humanPacketAge(iso) {
|
|
14139
|
+
const then = Date.parse(iso);
|
|
14140
|
+
if (!Number.isFinite(then))
|
|
14141
|
+
return "age unknown";
|
|
14142
|
+
const minutes = Math.max(0, Math.floor((Date.now() - then) / 60000));
|
|
14143
|
+
if (minutes < 60)
|
|
14144
|
+
return `${minutes}m ago`;
|
|
14145
|
+
const hours = Math.floor(minutes / 60);
|
|
14146
|
+
if (hours < 48)
|
|
14147
|
+
return `${hours}h ago`;
|
|
14148
|
+
const days = Math.floor(hours / 24);
|
|
14149
|
+
if (days < 60)
|
|
14150
|
+
return `${days}d ago`;
|
|
14151
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
14152
|
+
}
|
|
14153
|
+
// Timeline-as-index: resume shows a compact one-line-per-packet index of recent
|
|
14154
|
+
// memory instead of full packets — the agent recalls details on demand.
|
|
14155
|
+
const RESUME_TIMELINE_LIMIT = 15;
|
|
14156
|
+
const RESUME_TIMELINE_DETAILED = 3;
|
|
14157
|
+
const RESUME_CONTEXT_TOKEN_BUDGET = 800;
|
|
13434
14158
|
function kageResume(projectDir) {
|
|
13435
14159
|
ensureMemoryDirs(projectDir);
|
|
13436
14160
|
const approved = loadApprovedPackets(projectDir);
|
|
@@ -13470,7 +14194,18 @@ function kageResume(projectDir) {
|
|
|
13470
14194
|
const pendingAutoDistilled = pending.filter((packet) => packet.tags.includes(exports.AUTO_DISTILL_TAG)).length;
|
|
13471
14195
|
const reconciliation = kageMemoryReconciliation(projectDir, { limit: 5 });
|
|
13472
14196
|
const reconciliationItems = reconciliation.items.map((item) => ({ packet_id: item.packet_id, title: item.title }));
|
|
13473
|
-
const
|
|
14197
|
+
const recentPackets = [...approved, ...pending]
|
|
14198
|
+
.sort((a, b) => packetRecency(b).localeCompare(packetRecency(a)))
|
|
14199
|
+
.slice(0, RESUME_TIMELINE_LIMIT);
|
|
14200
|
+
const recentMemory = recentPackets.map((packet) => ({
|
|
14201
|
+
id: packet.id,
|
|
14202
|
+
type: packet.type,
|
|
14203
|
+
title: packet.title,
|
|
14204
|
+
updated_at: packetRecency(packet),
|
|
14205
|
+
age: humanPacketAge(packetRecency(packet)),
|
|
14206
|
+
}));
|
|
14207
|
+
const hasSessionContent = Boolean(lastSession || lastChangeMemory || pendingAutoDistilled || reconciliation.unresolved_count);
|
|
14208
|
+
const hasContent = hasSessionContent || recentMemory.length > 0;
|
|
13474
14209
|
const lines = [];
|
|
13475
14210
|
if (hasContent) {
|
|
13476
14211
|
lines.push("# Previously (Kage)");
|
|
@@ -13495,6 +14230,22 @@ function kageResume(projectDir) {
|
|
|
13495
14230
|
lines.push(` - ${item.packet_id}: ${item.title}`);
|
|
13496
14231
|
}
|
|
13497
14232
|
}
|
|
14233
|
+
// Compact timeline index: one line per recent packet, full detail (summary)
|
|
14234
|
+
// only for the newest few, hard-capped to the resume token budget.
|
|
14235
|
+
const block = lines.slice(0, 15);
|
|
14236
|
+
if (recentPackets.length) {
|
|
14237
|
+
block.push("", "## Recent memory");
|
|
14238
|
+
recentPackets.forEach((packet, position) => {
|
|
14239
|
+
const entry = `[${packet.id.slice(0, 12)}] ${packet.type} ${packet.title} (${humanPacketAge(packetRecency(packet))})`;
|
|
14240
|
+
const candidate = [entry];
|
|
14241
|
+
if (position < RESUME_TIMELINE_DETAILED && packet.summary) {
|
|
14242
|
+
const summary = packet.summary.replace(/\s+/g, " ").trim();
|
|
14243
|
+
candidate.push(` ${summary.length > 160 ? `${summary.slice(0, 157)}...` : summary}`);
|
|
14244
|
+
}
|
|
14245
|
+
if (estimateTokens([...block, ...candidate].join("\n")) <= RESUME_CONTEXT_TOKEN_BUDGET)
|
|
14246
|
+
block.push(...candidate);
|
|
14247
|
+
});
|
|
14248
|
+
}
|
|
13498
14249
|
return {
|
|
13499
14250
|
schema_version: 1,
|
|
13500
14251
|
project_dir: projectDir,
|
|
@@ -13506,7 +14257,8 @@ function kageResume(projectDir) {
|
|
|
13506
14257
|
pending_total: pending.length,
|
|
13507
14258
|
...(pendingAutoDistilled ? { review_command: `kage review --project ${projectDir}` } : {}),
|
|
13508
14259
|
reconciliation: { unresolved_count: reconciliation.unresolved_count, items: reconciliationItems },
|
|
13509
|
-
|
|
14260
|
+
recent_memory: recentMemory,
|
|
14261
|
+
context_block: block.join("\n"),
|
|
13510
14262
|
};
|
|
13511
14263
|
}
|
|
13512
14264
|
function createDiffChangeMemory(projectDir, summary) {
|
|
@@ -14385,18 +15137,21 @@ function installClaudeSettings(projectDir) {
|
|
|
14385
15137
|
writeJson(settingsPath, settings);
|
|
14386
15138
|
}
|
|
14387
15139
|
function initProject(projectDir, options = {}) {
|
|
14388
|
-
// Default init touches ONLY .agent_memory
|
|
14389
|
-
//
|
|
14390
|
-
//
|
|
15140
|
+
// Default init touches ONLY .agent_memory/ plus a one-line .gitattributes
|
|
15141
|
+
// entry wiring the kage-packet merge driver (idempotent; ends hand-resolved
|
|
15142
|
+
// packet JSON conflicts). Agent-policy files (AGENTS.md, CLAUDE.md) and
|
|
15143
|
+
// .claude/settings.json are repo-visible and reviewable, so writing them
|
|
15144
|
+
// requires explicit opt-in (`kage init --with-policy` or `kage policy`).
|
|
14391
15145
|
const policyInstalled = options.policy === true;
|
|
14392
15146
|
if (policyInstalled) {
|
|
14393
15147
|
installAgentPolicy(projectDir);
|
|
14394
15148
|
installClaudeSettings(projectDir);
|
|
14395
15149
|
}
|
|
15150
|
+
const gitAttributes = ensurePacketMergeAttributes(projectDir);
|
|
14396
15151
|
const index = indexProject(projectDir, { graphs: false });
|
|
14397
15152
|
const validation = validateProject(projectDir);
|
|
14398
15153
|
const sampleRecall = recallFromPackets("how do I run tests", loadApprovedPackets(projectDir), 5, "Repo Memory");
|
|
14399
|
-
return { index, validation, sampleRecall, policyInstalled };
|
|
15154
|
+
return { index, validation, sampleRecall, policyInstalled, gitAttributes };
|
|
14400
15155
|
}
|
|
14401
15156
|
function doctorProject(projectDir) {
|
|
14402
15157
|
ensureMemoryDirs(projectDir);
|
|
@@ -14490,6 +15245,82 @@ function resolveConflictedPacket(content) {
|
|
|
14490
15245
|
candidates.sort((a, b) => packetRecency(b).localeCompare(packetRecency(a)));
|
|
14491
15246
|
return candidates[0];
|
|
14492
15247
|
}
|
|
15248
|
+
// ---------------------------------------------------------------------------
|
|
15249
|
+
// Packet merge driver. `kage merge-packet <ours> <base> <theirs>` follows the
|
|
15250
|
+
// git merge-driver convention (%A %O %B: write the result to the ours path,
|
|
15251
|
+
// exit 0 on success, non-zero to leave the conflict). v1 policy is whole-file
|
|
15252
|
+
// newest-wins by updated_at — packets are single facts, so field-level merges
|
|
15253
|
+
// buy little over taking the most recently verified side.
|
|
15254
|
+
exports.PACKET_MERGE_ATTRIBUTE_LINE = ".agent_memory/packets/*.json merge=kage-packet";
|
|
15255
|
+
exports.PACKET_MERGE_DRIVER_CONFIG = 'git config merge.kage-packet.driver "npx -y @kage-core/kage-graph-mcp merge-packet %A %O %B"';
|
|
15256
|
+
function mergePacketFiles(oursPath, basePath, theirsPath) {
|
|
15257
|
+
void basePath; // Reserved for a future field-level three-way merge.
|
|
15258
|
+
const readSide = (path) => {
|
|
15259
|
+
const raw = safeReadText(path);
|
|
15260
|
+
if (raw === null)
|
|
15261
|
+
return null;
|
|
15262
|
+
try {
|
|
15263
|
+
const parsed = JSON.parse(raw);
|
|
15264
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
15265
|
+
return { raw, packet: parsed };
|
|
15266
|
+
}
|
|
15267
|
+
}
|
|
15268
|
+
catch {
|
|
15269
|
+
// A side that carries committed conflict markers (the exact failure mode
|
|
15270
|
+
// this driver exists to end) can often be recovered with repair's
|
|
15271
|
+
// conflict-splitting logic before giving up on it.
|
|
15272
|
+
const recovered = resolveConflictedPacket(raw);
|
|
15273
|
+
if (recovered)
|
|
15274
|
+
return { raw: `${JSON.stringify(recovered, null, 2)}\n`, packet: recovered };
|
|
15275
|
+
}
|
|
15276
|
+
return null;
|
|
15277
|
+
};
|
|
15278
|
+
const ours = readSide(oursPath);
|
|
15279
|
+
const theirs = readSide(theirsPath);
|
|
15280
|
+
if (!ours && !theirs) {
|
|
15281
|
+
return { ok: false, winner: null, detail: "kage merge-packet: neither side parses as packet JSON; leaving the conflict for manual resolution." };
|
|
15282
|
+
}
|
|
15283
|
+
let winner;
|
|
15284
|
+
if (ours && theirs) {
|
|
15285
|
+
winner = packetRecency(theirs.packet).localeCompare(packetRecency(ours.packet)) > 0 ? "theirs" : "ours";
|
|
15286
|
+
}
|
|
15287
|
+
else {
|
|
15288
|
+
winner = ours ? "ours" : "theirs";
|
|
15289
|
+
}
|
|
15290
|
+
const winning = winner === "ours" ? ours : theirs;
|
|
15291
|
+
try {
|
|
15292
|
+
(0, node_fs_1.writeFileSync)(oursPath, winning.raw, "utf8");
|
|
15293
|
+
}
|
|
15294
|
+
catch (error) {
|
|
15295
|
+
return { ok: false, winner: null, detail: `kage merge-packet: failed to write merge result: ${error instanceof Error ? error.message : String(error)}` };
|
|
15296
|
+
}
|
|
15297
|
+
const recency = packetRecency(winning.packet);
|
|
15298
|
+
return {
|
|
15299
|
+
ok: true,
|
|
15300
|
+
winner,
|
|
15301
|
+
detail: `kage merge-packet: kept ${winner} side (newest updated_at${recency ? ` ${recency}` : ""}).`,
|
|
15302
|
+
};
|
|
15303
|
+
}
|
|
15304
|
+
// Idempotently wire .gitattributes so packet JSON uses the kage-packet merge
|
|
15305
|
+
// driver. Re-runs never duplicate the line; a stale driver value on the same
|
|
15306
|
+
// pattern is replaced in place.
|
|
15307
|
+
function ensurePacketMergeAttributes(projectDir) {
|
|
15308
|
+
const path = (0, node_path_1.join)(projectDir, ".gitattributes");
|
|
15309
|
+
const existing = safeReadText(path) ?? "";
|
|
15310
|
+
const lines = existing.split(/\r?\n/);
|
|
15311
|
+
const pattern = /^\.agent_memory\/packets\/\*\.json\s+merge=/;
|
|
15312
|
+
const index = lines.findIndex((line) => pattern.test(line.trim()));
|
|
15313
|
+
if (index !== -1) {
|
|
15314
|
+
if (lines[index].trim() === exports.PACKET_MERGE_ATTRIBUTE_LINE)
|
|
15315
|
+
return { path, changed: false };
|
|
15316
|
+
lines[index] = exports.PACKET_MERGE_ATTRIBUTE_LINE;
|
|
15317
|
+
(0, node_fs_1.writeFileSync)(path, `${lines.join("\n").replace(/\n+$/, "")}\n`, "utf8");
|
|
15318
|
+
return { path, changed: true };
|
|
15319
|
+
}
|
|
15320
|
+
const prefix = existing.length ? (existing.endsWith("\n") ? existing : `${existing}\n`) : "";
|
|
15321
|
+
(0, node_fs_1.writeFileSync)(path, `${prefix}${exports.PACKET_MERGE_ATTRIBUTE_LINE}\n`, "utf8");
|
|
15322
|
+
return { path, changed: true };
|
|
15323
|
+
}
|
|
14493
15324
|
function repairProject(projectDir, options = {}) {
|
|
14494
15325
|
ensureMemoryDirs(projectDir);
|
|
14495
15326
|
const actions = [];
|
|
@@ -15045,3 +15876,509 @@ function kageMemoryTimeline(projectDir, days = 14) {
|
|
|
15045
15876
|
recommendations,
|
|
15046
15877
|
};
|
|
15047
15878
|
}
|
|
15879
|
+
// ---------------------------------------------------------------------------
|
|
15880
|
+
// Personal memory (~/.kage/memory) + kage sync — cross-machine continuity
|
|
15881
|
+
// (docs/CLOUD.md v1). Repo memory follows the repo through git; personal
|
|
15882
|
+
// memory follows the PERSON: packets live in the user's home store and sync
|
|
15883
|
+
// through the user's own private git remote. Trust rules stay structural:
|
|
15884
|
+
// personal packets may cite the current project's files (validated and
|
|
15885
|
+
// fingerprinted exactly like repo memory, re-verified against the local
|
|
15886
|
+
// checkout on every recall, in any clone) or carry no citations at all —
|
|
15887
|
+
// allowed ONLY here, and labeled unverifiable on recall. Personal packets
|
|
15888
|
+
// never enter repo flows (pr-check, stale-catch, refresh, access tracking).
|
|
15889
|
+
// ---------------------------------------------------------------------------
|
|
15890
|
+
function kageHomeDir() {
|
|
15891
|
+
const override = process.env.KAGE_HOME?.trim();
|
|
15892
|
+
return override ? (0, node_path_1.resolve)(override) : (0, node_path_1.join)((0, node_os_1.homedir)(), ".kage");
|
|
15893
|
+
}
|
|
15894
|
+
function personalMemoryDir() {
|
|
15895
|
+
return (0, node_path_1.join)(kageHomeDir(), "memory");
|
|
15896
|
+
}
|
|
15897
|
+
function personalPacketsDir() {
|
|
15898
|
+
return (0, node_path_1.join)(personalMemoryDir(), "packets");
|
|
15899
|
+
}
|
|
15900
|
+
function personalConflictsDir() {
|
|
15901
|
+
return (0, node_path_1.join)(personalMemoryDir(), "conflicts");
|
|
15902
|
+
}
|
|
15903
|
+
function loadPersonalPackets() {
|
|
15904
|
+
return loadPacketsFromDir(personalPacketsDir()).filter((packet) => packet.status === "approved");
|
|
15905
|
+
}
|
|
15906
|
+
function makePersonalPacketId(type, title, suffix) {
|
|
15907
|
+
return `personal:${type}:${slugify(`${title}-${suffix}`)}`;
|
|
15908
|
+
}
|
|
15909
|
+
function learnPersonal(input) {
|
|
15910
|
+
// Same privacy guarantee as repo learn: <private> spans never reach disk.
|
|
15911
|
+
input = {
|
|
15912
|
+
...input,
|
|
15913
|
+
learning: stripPrivateSpans(input.learning),
|
|
15914
|
+
title: input.title === undefined ? undefined : stripPrivateSpans(input.title),
|
|
15915
|
+
evidence: input.evidence === undefined ? undefined : stripPrivateSpans(input.evidence),
|
|
15916
|
+
verifiedBy: input.verifiedBy === undefined ? undefined : stripPrivateSpans(input.verifiedBy),
|
|
15917
|
+
};
|
|
15918
|
+
const type = inferLearningType(input);
|
|
15919
|
+
const title = input.title?.trim() || titleFromLearning(input.learning);
|
|
15920
|
+
const body = [
|
|
15921
|
+
input.learning.trim(),
|
|
15922
|
+
input.evidence ? `\nEvidence: ${input.evidence.trim()}` : "",
|
|
15923
|
+
input.verifiedBy ? `\nVerified by: ${input.verifiedBy.trim()}` : "",
|
|
15924
|
+
].join("").trim();
|
|
15925
|
+
return capturePersonal({
|
|
15926
|
+
projectDir: input.projectDir,
|
|
15927
|
+
title,
|
|
15928
|
+
summary: summarize(input.learning),
|
|
15929
|
+
body,
|
|
15930
|
+
type,
|
|
15931
|
+
tags: unique(["personal", ...(input.tags ?? [])]),
|
|
15932
|
+
paths: input.paths,
|
|
15933
|
+
stack: input.stack,
|
|
15934
|
+
context: input.context,
|
|
15935
|
+
allowMissingPaths: input.allowMissingPaths,
|
|
15936
|
+
discoveryTokens: input.discoveryTokens,
|
|
15937
|
+
});
|
|
15938
|
+
}
|
|
15939
|
+
function capturePersonal(input) {
|
|
15940
|
+
input = {
|
|
15941
|
+
...input,
|
|
15942
|
+
title: stripPrivateSpans(input.title),
|
|
15943
|
+
summary: input.summary === undefined ? undefined : stripPrivateSpans(input.summary),
|
|
15944
|
+
body: stripPrivateSpans(input.body),
|
|
15945
|
+
context: input.context ? stripPrivateFromContext(input.context) : input.context,
|
|
15946
|
+
};
|
|
15947
|
+
const type = input.type ?? "reference";
|
|
15948
|
+
if (!exports.MEMORY_TYPES.includes(type)) {
|
|
15949
|
+
return { ok: false, errors: [`Invalid memory type: ${type}`] };
|
|
15950
|
+
}
|
|
15951
|
+
// Personal memory syncs to a remote, so the secret scan matters MORE here, not less.
|
|
15952
|
+
const scanFindings = scanSensitiveText([input.title, input.summary ?? "", input.body].join("\n"));
|
|
15953
|
+
if (scanFindings.length) {
|
|
15954
|
+
return { ok: false, errors: [`Sensitive content blocked: ${unique(scanFindings).join(", ")}`] };
|
|
15955
|
+
}
|
|
15956
|
+
const warnings = [];
|
|
15957
|
+
const citedPaths = (input.paths ?? []).filter((path) => path && !isGroundingIgnored(input.projectDir, path));
|
|
15958
|
+
const meaningfulPaths = citedPaths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
|
|
15959
|
+
const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(input.projectDir, path));
|
|
15960
|
+
// Cited personal packets follow the SAME write-time rule as repo memory: a packet
|
|
15961
|
+
// whose every cited path is missing from the current project is a hallucinated
|
|
15962
|
+
// citation and is rejected. The citation-FREE case below is the only personal escape.
|
|
15963
|
+
if (meaningfulPaths.length && missingPaths.length === meaningfulPaths.length && !input.allowMissingPaths) {
|
|
15964
|
+
return {
|
|
15965
|
+
ok: false,
|
|
15966
|
+
errors: [
|
|
15967
|
+
`Citation validation failed: none of the referenced paths exist in this project: ${missingPaths.join(", ")}. ` +
|
|
15968
|
+
`Fix the paths, drop them for a citation-free personal note, or pass allow_missing_paths.`,
|
|
15969
|
+
],
|
|
15970
|
+
warnings: [],
|
|
15971
|
+
};
|
|
15972
|
+
}
|
|
15973
|
+
if (missingPaths.length) {
|
|
15974
|
+
warnings.push(`Some referenced paths do not exist in this project: ${missingPaths.join(", ")}`);
|
|
15975
|
+
}
|
|
15976
|
+
const createdAt = nowIso();
|
|
15977
|
+
const fingerprints = memoryPathFingerprints(input.projectDir, citedPaths);
|
|
15978
|
+
// Citation-free personal packets are allowed but structurally second-class:
|
|
15979
|
+
// marked unverifiable at write time so recall can label them as such.
|
|
15980
|
+
const unverifiable = fingerprints.length === 0;
|
|
15981
|
+
if (unverifiable) {
|
|
15982
|
+
warnings.push("Personal packet has no verifiable citations; recall will label it unverifiable.");
|
|
15983
|
+
}
|
|
15984
|
+
const packet = {
|
|
15985
|
+
schema_version: exports.PACKET_SCHEMA_VERSION,
|
|
15986
|
+
id: makePersonalPacketId(type, input.title, String(Date.now())),
|
|
15987
|
+
title: input.title.trim(),
|
|
15988
|
+
summary: (input.summary?.trim() || summarize(input.body)),
|
|
15989
|
+
body: input.body.trim(),
|
|
15990
|
+
type,
|
|
15991
|
+
scope: "personal",
|
|
15992
|
+
visibility: "private",
|
|
15993
|
+
sensitivity: "internal",
|
|
15994
|
+
status: "approved",
|
|
15995
|
+
confidence: DEFAULT_CONFIDENCE,
|
|
15996
|
+
tags: input.tags ?? [],
|
|
15997
|
+
paths: citedPaths,
|
|
15998
|
+
stack: input.stack ?? [],
|
|
15999
|
+
source_refs: [
|
|
16000
|
+
{
|
|
16001
|
+
kind: "personal_capture",
|
|
16002
|
+
captured_at: createdAt,
|
|
16003
|
+
project: repoDisplayName(input.projectDir),
|
|
16004
|
+
},
|
|
16005
|
+
],
|
|
16006
|
+
context: inferEngineeringContext({ title: input.title, body: input.body, context: input.context }),
|
|
16007
|
+
freshness: {
|
|
16008
|
+
ttl_days: 365,
|
|
16009
|
+
last_verified_at: createdAt,
|
|
16010
|
+
path_fingerprints: fingerprints,
|
|
16011
|
+
path_fingerprint_policy: "source_hash_staleness",
|
|
16012
|
+
verification: unverifiable ? "unverifiable_personal" : "personal_capture_cited",
|
|
16013
|
+
},
|
|
16014
|
+
edges: [],
|
|
16015
|
+
quality: {
|
|
16016
|
+
reviewer: "personal",
|
|
16017
|
+
votes_up: 0,
|
|
16018
|
+
votes_down: 0,
|
|
16019
|
+
uses_30d: 0,
|
|
16020
|
+
reports_stale: 0,
|
|
16021
|
+
unverifiable,
|
|
16022
|
+
review_boundary: "personal_store",
|
|
16023
|
+
promotion_requires_review: true,
|
|
16024
|
+
...(Number.isFinite(Number(input.discoveryTokens)) && Number(input.discoveryTokens) > 0
|
|
16025
|
+
? { discovery_tokens: Math.floor(Number(input.discoveryTokens)), discovery_tokens_estimated: false }
|
|
16026
|
+
: { discovery_tokens: defaultDiscoveryTokens(type), discovery_tokens_estimated: true }),
|
|
16027
|
+
},
|
|
16028
|
+
created_at: createdAt,
|
|
16029
|
+
updated_at: createdAt,
|
|
16030
|
+
author_branch: null,
|
|
16031
|
+
};
|
|
16032
|
+
const validation = validatePacket(packet);
|
|
16033
|
+
if (!validation.ok)
|
|
16034
|
+
return { ok: false, errors: validation.errors, warnings };
|
|
16035
|
+
ensureDir(personalPacketsDir());
|
|
16036
|
+
const path = (0, node_path_1.join)(personalPacketsDir(), packetFileName(packet));
|
|
16037
|
+
writeJson(path, packet);
|
|
16038
|
+
return { ok: true, packet, path, errors: [], warnings };
|
|
16039
|
+
}
|
|
16040
|
+
// Personal-memory candidates for a repo recall. Cited packets are re-verified
|
|
16041
|
+
// against THIS checkout (relative paths + content fingerprints), so the same
|
|
16042
|
+
// packet that recalls fine in one clone is withheld in a repo where its
|
|
16043
|
+
// evidence does not exist — the docs/CLOUD.md "verified sync" rule.
|
|
16044
|
+
function personalRecallEntries(projectDir, terms, limit = 3) {
|
|
16045
|
+
const packets = loadPersonalPackets();
|
|
16046
|
+
if (!packets.length)
|
|
16047
|
+
return [];
|
|
16048
|
+
const cache = new Map();
|
|
16049
|
+
const eligible = packets.filter((packet) => recallHardStaleReason(projectDir, packet, cache) === null);
|
|
16050
|
+
if (!eligible.length)
|
|
16051
|
+
return [];
|
|
16052
|
+
const scores = scorePacketsBm25(terms, eligible);
|
|
16053
|
+
return eligible
|
|
16054
|
+
.map((packet) => {
|
|
16055
|
+
const { score, why } = scores.get(packet.id) ?? { score: 0, why: [] };
|
|
16056
|
+
return {
|
|
16057
|
+
packet,
|
|
16058
|
+
score,
|
|
16059
|
+
why_matched: why,
|
|
16060
|
+
unverifiable: packetStoredPathFingerprints(packet).length === 0,
|
|
16061
|
+
};
|
|
16062
|
+
})
|
|
16063
|
+
.filter((entry) => entry.score > 0)
|
|
16064
|
+
.sort((a, b) => b.score - a.score || a.packet.title.localeCompare(b.packet.title))
|
|
16065
|
+
.slice(0, Math.max(1, limit));
|
|
16066
|
+
}
|
|
16067
|
+
function runSyncGit(cwd, args) {
|
|
16068
|
+
try {
|
|
16069
|
+
const stdout = (0, node_child_process_1.execFileSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
16070
|
+
return { ok: true, stdout: String(stdout ?? "").trim(), stderr: "" };
|
|
16071
|
+
}
|
|
16072
|
+
catch (error) {
|
|
16073
|
+
const failed = error;
|
|
16074
|
+
return {
|
|
16075
|
+
ok: false,
|
|
16076
|
+
stdout: String(failed.stdout ?? "").trim(),
|
|
16077
|
+
stderr: String(failed.stderr ?? failed.message ?? "git command failed").trim(),
|
|
16078
|
+
};
|
|
16079
|
+
}
|
|
16080
|
+
}
|
|
16081
|
+
// Commits must work on machines without a global git identity (fresh CI boxes,
|
|
16082
|
+
// brand-new laptops); fall back to a kage-sync identity instead of failing.
|
|
16083
|
+
function syncIdentityArgs(memoryDir) {
|
|
16084
|
+
const email = runSyncGit(memoryDir, ["config", "user.email"]);
|
|
16085
|
+
return email.ok && email.stdout ? [] : ["-c", "user.name=kage-sync", "-c", "user.email=kage-sync@localhost"];
|
|
16086
|
+
}
|
|
16087
|
+
const SYNC_SETUP_HINT = "Run: kage sync setup --remote <git-url>";
|
|
16088
|
+
function syncPacketFile(file) {
|
|
16089
|
+
return file.startsWith("packets/") && file.endsWith(".json");
|
|
16090
|
+
}
|
|
16091
|
+
function countSyncPacketFiles(namesOutput) {
|
|
16092
|
+
return namesOutput
|
|
16093
|
+
.split("\n")
|
|
16094
|
+
.map((line) => line.trim())
|
|
16095
|
+
.filter(Boolean)
|
|
16096
|
+
.filter(syncPacketFile)
|
|
16097
|
+
.length;
|
|
16098
|
+
}
|
|
16099
|
+
function syncRemoteDefaultBranch(memoryDir) {
|
|
16100
|
+
// "ref: refs/heads/<branch>\tHEAD" — empty remotes return nothing.
|
|
16101
|
+
const result = runSyncGit(memoryDir, ["ls-remote", "--symref", "origin", "HEAD"]);
|
|
16102
|
+
if (!result.ok)
|
|
16103
|
+
return null;
|
|
16104
|
+
const match = result.stdout.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m);
|
|
16105
|
+
return match?.[1] ?? null;
|
|
16106
|
+
}
|
|
16107
|
+
// Auto-resolve a rebase conflict on a packet file: newest updated_at wins
|
|
16108
|
+
// (same policy as the kage-packet merge driver), the losing version is
|
|
16109
|
+
// preserved under conflicts/<name>.<unix-ts>.json so no data is ever lost,
|
|
16110
|
+
// and the worktree file is rewritten as clean JSON — never conflict markers.
|
|
16111
|
+
function resolvePacketSyncConflict(memoryDir, file) {
|
|
16112
|
+
// During a rebase, stage 2 ("ours") is the upstream side already in the new
|
|
16113
|
+
// history; stage 3 ("theirs") is the local commit being replayed.
|
|
16114
|
+
const readStage = (stage) => {
|
|
16115
|
+
const show = runSyncGit(memoryDir, ["show", `:${stage}:${file}`]);
|
|
16116
|
+
if (!show.ok)
|
|
16117
|
+
return null;
|
|
16118
|
+
try {
|
|
16119
|
+
const parsed = JSON.parse(show.stdout);
|
|
16120
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
16121
|
+
return parsed;
|
|
16122
|
+
}
|
|
16123
|
+
catch {
|
|
16124
|
+
// An unparsable side loses to a parsable one.
|
|
16125
|
+
}
|
|
16126
|
+
return null;
|
|
16127
|
+
};
|
|
16128
|
+
const upstreamSide = readStage(2);
|
|
16129
|
+
const localSide = readStage(3);
|
|
16130
|
+
if (!upstreamSide && !localSide) {
|
|
16131
|
+
return { ok: false, error: `kage sync: neither side of ${file} parses as packet JSON; resolve manually in ${memoryDir}.` };
|
|
16132
|
+
}
|
|
16133
|
+
const localWins = Boolean(localSide && (!upstreamSide || packetRecency(localSide).localeCompare(packetRecency(upstreamSide)) > 0));
|
|
16134
|
+
const winner = localWins ? localSide : upstreamSide;
|
|
16135
|
+
const loser = localWins ? upstreamSide : localSide;
|
|
16136
|
+
writeJson((0, node_path_1.join)(memoryDir, file), winner);
|
|
16137
|
+
let backupPath;
|
|
16138
|
+
if (loser) {
|
|
16139
|
+
ensureDir(personalConflictsDir());
|
|
16140
|
+
const base = (0, node_path_1.basename)(file, ".json");
|
|
16141
|
+
let candidate = (0, node_path_1.join)(personalConflictsDir(), `${base}.${Math.floor(Date.now() / 1000)}.json`);
|
|
16142
|
+
let counter = 1;
|
|
16143
|
+
while ((0, node_fs_1.existsSync)(candidate)) {
|
|
16144
|
+
candidate = (0, node_path_1.join)(personalConflictsDir(), `${base}.${Math.floor(Date.now() / 1000)}-${counter}.json`);
|
|
16145
|
+
counter += 1;
|
|
16146
|
+
}
|
|
16147
|
+
writeJson(candidate, loser);
|
|
16148
|
+
backupPath = candidate;
|
|
16149
|
+
}
|
|
16150
|
+
return { ok: true, backupPath };
|
|
16151
|
+
}
|
|
16152
|
+
function rebaseOntoUpstream(memoryDir, upstream) {
|
|
16153
|
+
const conflictBackups = [];
|
|
16154
|
+
let resolved = 0;
|
|
16155
|
+
let step = runSyncGit(memoryDir, ["-c", "core.editor=true", "rebase", upstream]);
|
|
16156
|
+
while (!step.ok) {
|
|
16157
|
+
const conflicted = runSyncGit(memoryDir, ["diff", "--name-only", "--diff-filter=U"])
|
|
16158
|
+
.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
16159
|
+
if (!conflicted.length) {
|
|
16160
|
+
// The resolved commit became empty (we kept the upstream side wholesale).
|
|
16161
|
+
const skip = runSyncGit(memoryDir, ["-c", "core.editor=true", "rebase", "--skip"]);
|
|
16162
|
+
if (skip.ok) {
|
|
16163
|
+
step = skip;
|
|
16164
|
+
continue;
|
|
16165
|
+
}
|
|
16166
|
+
runSyncGit(memoryDir, ["rebase", "--abort"]);
|
|
16167
|
+
return { ok: false, resolved, conflictBackups, error: `git rebase failed: ${step.stderr || skip.stderr}` };
|
|
16168
|
+
}
|
|
16169
|
+
for (const file of conflicted) {
|
|
16170
|
+
if (!syncPacketFile(file)) {
|
|
16171
|
+
runSyncGit(memoryDir, ["rebase", "--abort"]);
|
|
16172
|
+
return { ok: false, resolved, conflictBackups, error: `kage sync only auto-resolves packets/*.json conflicts; ${file} needs manual resolution in ${memoryDir}.` };
|
|
16173
|
+
}
|
|
16174
|
+
const resolution = resolvePacketSyncConflict(memoryDir, file);
|
|
16175
|
+
if (!resolution.ok) {
|
|
16176
|
+
runSyncGit(memoryDir, ["rebase", "--abort"]);
|
|
16177
|
+
return { ok: false, resolved, conflictBackups, error: resolution.error };
|
|
16178
|
+
}
|
|
16179
|
+
if (resolution.backupPath)
|
|
16180
|
+
conflictBackups.push(resolution.backupPath);
|
|
16181
|
+
resolved += 1;
|
|
16182
|
+
const toAdd = [file, ...(resolution.backupPath ? [(0, node_path_1.relative)(memoryDir, resolution.backupPath)] : [])];
|
|
16183
|
+
runSyncGit(memoryDir, ["add", "--", ...toAdd]);
|
|
16184
|
+
}
|
|
16185
|
+
step = runSyncGit(memoryDir, ["-c", "core.editor=true", "rebase", "--continue"]);
|
|
16186
|
+
}
|
|
16187
|
+
return { ok: true, resolved, conflictBackups };
|
|
16188
|
+
}
|
|
16189
|
+
function syncSetup(remoteUrl) {
|
|
16190
|
+
const memoryDir = personalMemoryDir();
|
|
16191
|
+
ensureDir(personalPacketsDir());
|
|
16192
|
+
const result = {
|
|
16193
|
+
ok: false,
|
|
16194
|
+
memory_dir: memoryDir,
|
|
16195
|
+
remote: remoteUrl,
|
|
16196
|
+
initialized: false,
|
|
16197
|
+
remote_updated: false,
|
|
16198
|
+
branch: null,
|
|
16199
|
+
pushed: false,
|
|
16200
|
+
errors: [],
|
|
16201
|
+
};
|
|
16202
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(memoryDir, ".git"))) {
|
|
16203
|
+
const init = runSyncGit(memoryDir, ["init"]);
|
|
16204
|
+
if (!init.ok) {
|
|
16205
|
+
result.errors.push(`git init failed: ${init.stderr}`);
|
|
16206
|
+
return result;
|
|
16207
|
+
}
|
|
16208
|
+
result.initialized = true;
|
|
16209
|
+
}
|
|
16210
|
+
// Keep the packets dir trackable even before the first capture.
|
|
16211
|
+
const keep = (0, node_path_1.join)(personalPacketsDir(), ".gitkeep");
|
|
16212
|
+
if (!(0, node_fs_1.existsSync)(keep))
|
|
16213
|
+
(0, node_fs_1.writeFileSync)(keep, "", "utf8");
|
|
16214
|
+
const currentRemote = runSyncGit(memoryDir, ["remote", "get-url", "origin"]);
|
|
16215
|
+
if (!currentRemote.ok) {
|
|
16216
|
+
const added = runSyncGit(memoryDir, ["remote", "add", "origin", remoteUrl]);
|
|
16217
|
+
if (!added.ok) {
|
|
16218
|
+
result.errors.push(`git remote add failed: ${added.stderr}`);
|
|
16219
|
+
return result;
|
|
16220
|
+
}
|
|
16221
|
+
result.remote_updated = true;
|
|
16222
|
+
}
|
|
16223
|
+
else if (currentRemote.stdout !== remoteUrl) {
|
|
16224
|
+
const updated = runSyncGit(memoryDir, ["remote", "set-url", "origin", remoteUrl]);
|
|
16225
|
+
if (!updated.ok) {
|
|
16226
|
+
result.errors.push(`git remote set-url failed: ${updated.stderr}`);
|
|
16227
|
+
return result;
|
|
16228
|
+
}
|
|
16229
|
+
result.remote_updated = true;
|
|
16230
|
+
}
|
|
16231
|
+
// Commit local state before aligning with the remote.
|
|
16232
|
+
runSyncGit(memoryDir, ["add", "-A"]);
|
|
16233
|
+
const status = runSyncGit(memoryDir, ["status", "--porcelain"]);
|
|
16234
|
+
const hasHead = runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", "HEAD"]).ok;
|
|
16235
|
+
if (status.stdout || !hasHead) {
|
|
16236
|
+
const commit = runSyncGit(memoryDir, [
|
|
16237
|
+
...syncIdentityArgs(memoryDir),
|
|
16238
|
+
"commit",
|
|
16239
|
+
"-m",
|
|
16240
|
+
"kage sync setup",
|
|
16241
|
+
...(status.stdout ? [] : ["--allow-empty"]),
|
|
16242
|
+
]);
|
|
16243
|
+
if (!commit.ok) {
|
|
16244
|
+
result.errors.push(`git commit failed: ${commit.stderr}`);
|
|
16245
|
+
return result;
|
|
16246
|
+
}
|
|
16247
|
+
}
|
|
16248
|
+
const fetch = runSyncGit(memoryDir, ["fetch", "origin"]);
|
|
16249
|
+
if (!fetch.ok) {
|
|
16250
|
+
result.errors.push(`git fetch failed: ${fetch.stderr}`);
|
|
16251
|
+
return result;
|
|
16252
|
+
}
|
|
16253
|
+
// A second machine pointing at an existing remote must converge on the
|
|
16254
|
+
// remote's branch instead of pushing a parallel one: rename the local branch
|
|
16255
|
+
// to match and rebase local commits (the setup commit) on top.
|
|
16256
|
+
const remoteBranch = syncRemoteDefaultBranch(memoryDir);
|
|
16257
|
+
if (remoteBranch && runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", `origin/${remoteBranch}`]).ok) {
|
|
16258
|
+
const renamed = runSyncGit(memoryDir, ["branch", "-M", remoteBranch]);
|
|
16259
|
+
if (!renamed.ok) {
|
|
16260
|
+
result.errors.push(`git branch -M failed: ${renamed.stderr}`);
|
|
16261
|
+
return result;
|
|
16262
|
+
}
|
|
16263
|
+
const rebase = rebaseOntoUpstream(memoryDir, `origin/${remoteBranch}`);
|
|
16264
|
+
if (!rebase.ok) {
|
|
16265
|
+
result.errors.push(rebase.error ?? "git rebase failed");
|
|
16266
|
+
return result;
|
|
16267
|
+
}
|
|
16268
|
+
}
|
|
16269
|
+
result.branch = runSyncGit(memoryDir, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout || null;
|
|
16270
|
+
const push = runSyncGit(memoryDir, ["push", "-u", "origin", "HEAD"]);
|
|
16271
|
+
if (!push.ok) {
|
|
16272
|
+
result.errors.push(`git push failed: ${push.stderr}`);
|
|
16273
|
+
return result;
|
|
16274
|
+
}
|
|
16275
|
+
result.pushed = true;
|
|
16276
|
+
result.ok = true;
|
|
16277
|
+
return result;
|
|
16278
|
+
}
|
|
16279
|
+
function syncStatus() {
|
|
16280
|
+
const memoryDir = personalMemoryDir();
|
|
16281
|
+
const result = {
|
|
16282
|
+
ok: false,
|
|
16283
|
+
memory_dir: memoryDir,
|
|
16284
|
+
remote: null,
|
|
16285
|
+
branch: null,
|
|
16286
|
+
ahead: 0,
|
|
16287
|
+
behind: 0,
|
|
16288
|
+
dirty: false,
|
|
16289
|
+
warnings: [],
|
|
16290
|
+
errors: [],
|
|
16291
|
+
};
|
|
16292
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(memoryDir, ".git"))) {
|
|
16293
|
+
result.errors.push(`Personal memory store is not set up for sync. ${SYNC_SETUP_HINT}`);
|
|
16294
|
+
return result;
|
|
16295
|
+
}
|
|
16296
|
+
const remote = runSyncGit(memoryDir, ["remote", "get-url", "origin"]);
|
|
16297
|
+
if (!remote.ok) {
|
|
16298
|
+
result.errors.push(`No sync remote configured. ${SYNC_SETUP_HINT}`);
|
|
16299
|
+
return result;
|
|
16300
|
+
}
|
|
16301
|
+
result.remote = remote.stdout;
|
|
16302
|
+
// Status is read-only on the network: fetch only, never pull/push.
|
|
16303
|
+
const fetch = runSyncGit(memoryDir, ["fetch", "origin"]);
|
|
16304
|
+
if (!fetch.ok)
|
|
16305
|
+
result.warnings.push(`git fetch failed (showing last-known remote state): ${fetch.stderr}`);
|
|
16306
|
+
result.branch = runSyncGit(memoryDir, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout || null;
|
|
16307
|
+
const upstream = result.branch ? `origin/${result.branch}` : null;
|
|
16308
|
+
if (upstream && runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", upstream]).ok) {
|
|
16309
|
+
const counts = runSyncGit(memoryDir, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
|
|
16310
|
+
if (counts.ok) {
|
|
16311
|
+
const [ahead, behind] = counts.stdout.split(/\s+/).map((value) => Number(value));
|
|
16312
|
+
result.ahead = Number.isFinite(ahead) ? ahead : 0;
|
|
16313
|
+
result.behind = Number.isFinite(behind) ? behind : 0;
|
|
16314
|
+
}
|
|
16315
|
+
}
|
|
16316
|
+
result.dirty = Boolean(runSyncGit(memoryDir, ["status", "--porcelain"]).stdout);
|
|
16317
|
+
result.ok = true;
|
|
16318
|
+
return result;
|
|
16319
|
+
}
|
|
16320
|
+
function syncPersonal() {
|
|
16321
|
+
const memoryDir = personalMemoryDir();
|
|
16322
|
+
const result = {
|
|
16323
|
+
ok: false,
|
|
16324
|
+
memory_dir: memoryDir,
|
|
16325
|
+
remote: null,
|
|
16326
|
+
pushed: 0,
|
|
16327
|
+
pulled: 0,
|
|
16328
|
+
resolved: 0,
|
|
16329
|
+
conflict_backups: [],
|
|
16330
|
+
errors: [],
|
|
16331
|
+
};
|
|
16332
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(memoryDir, ".git"))) {
|
|
16333
|
+
result.errors.push(`Personal memory store is not set up for sync. ${SYNC_SETUP_HINT}`);
|
|
16334
|
+
return result;
|
|
16335
|
+
}
|
|
16336
|
+
const remote = runSyncGit(memoryDir, ["remote", "get-url", "origin"]);
|
|
16337
|
+
if (!remote.ok) {
|
|
16338
|
+
result.errors.push(`No sync remote configured. ${SYNC_SETUP_HINT}`);
|
|
16339
|
+
return result;
|
|
16340
|
+
}
|
|
16341
|
+
result.remote = remote.stdout;
|
|
16342
|
+
// 1. Commit local packet changes.
|
|
16343
|
+
runSyncGit(memoryDir, ["add", "-A"]);
|
|
16344
|
+
if (runSyncGit(memoryDir, ["status", "--porcelain"]).stdout) {
|
|
16345
|
+
const commit = runSyncGit(memoryDir, [...syncIdentityArgs(memoryDir), "commit", "-m", `kage sync ${nowIso()}`]);
|
|
16346
|
+
if (!commit.ok) {
|
|
16347
|
+
result.errors.push(`git commit failed: ${commit.stderr}`);
|
|
16348
|
+
return result;
|
|
16349
|
+
}
|
|
16350
|
+
}
|
|
16351
|
+
// 2. Pull --rebase (split into fetch + rebase so the receipt can count what came in).
|
|
16352
|
+
const fetch = runSyncGit(memoryDir, ["fetch", "origin"]);
|
|
16353
|
+
if (!fetch.ok) {
|
|
16354
|
+
result.errors.push(`git fetch failed: ${fetch.stderr}`);
|
|
16355
|
+
return result;
|
|
16356
|
+
}
|
|
16357
|
+
const branch = runSyncGit(memoryDir, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout;
|
|
16358
|
+
const upstream = `origin/${branch}`;
|
|
16359
|
+
const hasUpstream = Boolean(branch) && runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", upstream]).ok;
|
|
16360
|
+
if (hasUpstream) {
|
|
16361
|
+
// Their side of the merge base: packet files this sync will bring in.
|
|
16362
|
+
const incoming = runSyncGit(memoryDir, ["diff", "--name-only", `HEAD...${upstream}`]);
|
|
16363
|
+
result.pulled = countSyncPacketFiles(incoming.stdout);
|
|
16364
|
+
const rebase = rebaseOntoUpstream(memoryDir, upstream);
|
|
16365
|
+
result.resolved = rebase.resolved;
|
|
16366
|
+
result.conflict_backups = rebase.conflictBackups;
|
|
16367
|
+
if (!rebase.ok) {
|
|
16368
|
+
result.errors.push(rebase.error ?? "git rebase failed");
|
|
16369
|
+
return result;
|
|
16370
|
+
}
|
|
16371
|
+
}
|
|
16372
|
+
// 3. Push (first push sets the upstream).
|
|
16373
|
+
const outgoing = hasUpstream
|
|
16374
|
+
? runSyncGit(memoryDir, ["diff", "--name-only", `${upstream}..HEAD`])
|
|
16375
|
+
: runSyncGit(memoryDir, ["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
16376
|
+
result.pushed = countSyncPacketFiles(outgoing.stdout);
|
|
16377
|
+
const push = runSyncGit(memoryDir, ["push", "-u", "origin", "HEAD"]);
|
|
16378
|
+
if (!push.ok) {
|
|
16379
|
+
result.errors.push(`git push failed: ${push.stderr}`);
|
|
16380
|
+
return result;
|
|
16381
|
+
}
|
|
16382
|
+
result.ok = true;
|
|
16383
|
+
return result;
|
|
16384
|
+
}
|