claude-mem-lite 2.91.0 → 2.93.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/adopt-cli.mjs +19 -9
- package/bash-utils.mjs +45 -5
- package/cli/activity.mjs +12 -4
- package/cli/common.mjs +23 -0
- package/format-utils.mjs +12 -1
- package/hook-handoff.mjs +20 -2
- package/hook-llm.mjs +22 -41
- package/hook-optimize.mjs +23 -8
- package/hook-update.mjs +16 -5
- package/hook.mjs +8 -1
- package/lib/citation-tracker.mjs +15 -0
- package/lib/maintain-core.mjs +82 -22
- package/lib/observation-write.mjs +67 -0
- package/lib/save-observation.mjs +12 -26
- package/mem-cli.mjs +36 -26
- package/memdir.mjs +36 -11
- package/nlp.mjs +20 -3
- package/package.json +3 -2
- package/project-utils.mjs +6 -0
- package/registry-importer.mjs +8 -3
- package/registry-retriever.mjs +10 -6
- package/registry.mjs +0 -132
- package/schema.mjs +15 -8
- package/search-engine.mjs +14 -1
- package/secret-scrub.mjs +12 -3
- package/server.mjs +9 -1
- package/source-files.mjs +5 -0
- package/tier.mjs +5 -2
- package/utils.mjs +40 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.93.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.93.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/adopt-cli.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { join } from 'path';
|
|
|
15
15
|
import {
|
|
16
16
|
memdirPath, writePluginSection, removePluginSection,
|
|
17
17
|
writePluginDoc, removePluginDoc,
|
|
18
|
-
isAdopted, readMemoryIndex,
|
|
18
|
+
isAdopted, hasPluginState, readMemoryIndex,
|
|
19
19
|
UserEditedError, BudgetExceededError,
|
|
20
20
|
} from './memdir.mjs';
|
|
21
21
|
import {
|
|
@@ -325,6 +325,7 @@ export function cmdUnadopt(args = []) {
|
|
|
325
325
|
|
|
326
326
|
const all = hasFlag(args, '--all');
|
|
327
327
|
const dryRun = hasFlag(args, '--dry-run');
|
|
328
|
+
const force = hasFlag(args, '--force');
|
|
328
329
|
const targets = all
|
|
329
330
|
? listAllMemdirs().map((m) => m.memdir)
|
|
330
331
|
: [memdirPath(detectCwd())];
|
|
@@ -334,23 +335,32 @@ export function cmdUnadopt(args = []) {
|
|
|
334
335
|
return;
|
|
335
336
|
}
|
|
336
337
|
|
|
337
|
-
let removed = 0, absent = 0;
|
|
338
|
+
let removed = 0, absent = 0, skipped = 0;
|
|
338
339
|
for (const memdir of targets) {
|
|
339
340
|
if (dryRun) {
|
|
340
|
-
|
|
341
|
-
|
|
341
|
+
// Mirror the live foreign-content guard: a sentinel with no state sidecar would be
|
|
342
|
+
// skipped (not removed) unless --force, so dry-run must report it the same way.
|
|
343
|
+
const action = !isAdopted(memdir, PLUGIN_SLUG) ? 'absent'
|
|
344
|
+
: (hasPluginState(memdir, PLUGIN_SLUG) || force) ? 'would-remove'
|
|
345
|
+
: 'would-skip-foreign';
|
|
342
346
|
log(`[unadopt --dry-run] ${memdir} → ${action}`);
|
|
343
|
-
if (
|
|
347
|
+
if (action === 'would-remove') removed++;
|
|
348
|
+
else if (action === 'would-skip-foreign') skipped++;
|
|
349
|
+
else absent++;
|
|
344
350
|
continue;
|
|
345
351
|
}
|
|
346
|
-
const r = removePluginSection(memdir, PLUGIN_SLUG);
|
|
347
|
-
removePluginDoc(memdir, PLUGIN_SLUG);
|
|
348
|
-
if (r.action === '
|
|
352
|
+
const r = removePluginSection(memdir, PLUGIN_SLUG, { force });
|
|
353
|
+
if (r.action === 'removed') { removePluginDoc(memdir, PLUGIN_SLUG); removed++; }
|
|
354
|
+
else if (r.action === 'skipped-foreign') skipped++;
|
|
349
355
|
else absent++;
|
|
350
356
|
log(`[unadopt] ${memdir} → ${r.action}`);
|
|
351
357
|
}
|
|
352
358
|
|
|
359
|
+
if (skipped > 0) {
|
|
360
|
+
log('[unadopt] skipped-foreign = a sentinel block with no plugin state file (not proven plugin-written).');
|
|
361
|
+
log('[unadopt] pass --force to remove it anyway.');
|
|
362
|
+
}
|
|
353
363
|
log('');
|
|
354
364
|
const verb = dryRun ? 'would remove' : 'removed';
|
|
355
|
-
log(`[unadopt${dryRun ? ' --dry-run' : ''}] ${targets.length} target(s): ${removed} ${verb}, ${absent} absent`);
|
|
365
|
+
log(`[unadopt${dryRun ? ' --dry-run' : ''}] ${targets.length} target(s): ${removed} ${verb}, ${skipped} skipped-foreign, ${absent} absent`);
|
|
356
366
|
}
|
package/bash-utils.mjs
CHANGED
|
@@ -3,6 +3,38 @@
|
|
|
3
3
|
|
|
4
4
|
import { basename } from 'path';
|
|
5
5
|
|
|
6
|
+
// Read/search commands whose output legitimately contains "error"-like keywords without
|
|
7
|
+
// being a failure. Matched against the PRIMARY command (see isReadOnlyCommand).
|
|
8
|
+
const SEARCH_VERBS = new Set([
|
|
9
|
+
'grep', 'rg', 'ag', 'ack', 'cat', 'head', 'tail', 'less', 'more', 'find', 'locate', 'wc', 'file', 'which', 'type',
|
|
10
|
+
]);
|
|
11
|
+
// Command prefixes that wrap the real command (env-assignments handled separately).
|
|
12
|
+
const CMD_WRAPPERS = new Set(['sudo', 'doas', 'env', 'time', 'command', 'nice', 'nohup', 'stdbuf', 'xargs']);
|
|
13
|
+
// git read subcommands whose output contains commit/log/match text, not failures.
|
|
14
|
+
const GIT_READ_SUBCMDS = new Set(['grep', 'log', 'show', 'diff', 'blame', 'ls-files', 'cat-file', 'whatchanged', 'shortlog', 'reflog', 'status']);
|
|
15
|
+
|
|
16
|
+
// True when the command's PRIMARY operation (left of the first pipe, past any
|
|
17
|
+
// env-assignments / wrapper like `sudo`/`env`/`time`) is a read/search — including
|
|
18
|
+
// `git grep`/`git log`. Anchoring on the primary command (not "search verb appears
|
|
19
|
+
// anywhere") is what lets `npm run build 2>&1 | tail` stay an error while `sudo grep`,
|
|
20
|
+
// `git grep`, `cat f | head` are correctly exempt.
|
|
21
|
+
function isReadOnlyCommand(cmd) {
|
|
22
|
+
const primary = cmd.split('|')[0];
|
|
23
|
+
const toks = primary.trim().split(/\s+/).filter(Boolean);
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i < toks.length && (/^\w+=/.test(toks[i]) || CMD_WRAPPERS.has(toks[i]))) i++;
|
|
26
|
+
const first = toks[i];
|
|
27
|
+
if (!first) return false;
|
|
28
|
+
if (SEARCH_VERBS.has(first)) return true;
|
|
29
|
+
return first === 'git' && GIT_READ_SUBCMDS.has(toks[i + 1]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Paths excluded from observation capture (ephemeral / virtual filesystems) — applied
|
|
33
|
+
// uniformly to both command-parsed paths and direct file_path/path/filePath fields.
|
|
34
|
+
function isExcludedPath(p) {
|
|
35
|
+
return p.startsWith('/dev/') || p.startsWith('/proc/') || p.startsWith('/tmp/');
|
|
36
|
+
}
|
|
37
|
+
|
|
6
38
|
/**
|
|
7
39
|
* Detect significance signals in a Bash command and its response.
|
|
8
40
|
* Checks for errors, test runs, builds, git operations, and deployments.
|
|
@@ -12,9 +44,12 @@ import { basename } from 'path';
|
|
|
12
44
|
*/
|
|
13
45
|
export function detectBashSignificance(input, response) {
|
|
14
46
|
const cmd = (input.command || '').toLowerCase();
|
|
15
|
-
// Skip error keyword matching when the command is a read/search
|
|
16
|
-
//
|
|
17
|
-
|
|
47
|
+
// Skip error keyword matching only when the PRIMARY command is a read/search op (its
|
|
48
|
+
// output naturally contains "error"-like keywords that aren't failures). Anchored on the
|
|
49
|
+
// primary command — NOT "search verb appears anywhere" — so `npm run build 2>&1 | tail`
|
|
50
|
+
// stays a real failure while `sudo grep`, `git grep`, `git log --grep`, `cat f | head`
|
|
51
|
+
// remain exempt and `run-cat-tests` doesn't trip a substring match.
|
|
52
|
+
const isSearchCmd = isReadOnlyCommand(cmd);
|
|
18
53
|
const looksLikeError = !isSearchCmd
|
|
19
54
|
&& /\berror\b|\bERR!|fail(ed|ure)?|exception|panic|traceback|errno|enoent|command not found/i.test(response)
|
|
20
55
|
&& response.length > 15;
|
|
@@ -38,7 +73,9 @@ export function detectBashSignificance(input, response) {
|
|
|
38
73
|
const isTest = /\b(npm\s+test|npm\s+run\s+test|yarn\s+test|pnpm\s+test|pnpm\s+run\s+test|bun\s+test|go\s+test|cargo\s+test)\b/i.test(cmd)
|
|
39
74
|
|| /\b(jest|pytest|vitest|mocha|cypress|playwright)\b/i.test(cmd);
|
|
40
75
|
const isBuild = /\b(build|compile|tsc|webpack|vite|rollup|esbuild|make|cargo)\b/i.test(cmd);
|
|
41
|
-
|
|
76
|
+
// Allow intervening global git options (`-C <path>`, `-c k=v`, `--no-pager`, …) between
|
|
77
|
+
// `git` and the subcommand — `git -C /repo push` is the standard multi-repo/scripted form.
|
|
78
|
+
const isGit = /\bgit\s+(?:(?:-[cC]\s+\S+|--?[\w-]+(?:=\S+)?)\s+)*(commit|merge|rebase|cherry-pick|push)\b/i.test(cmd);
|
|
42
79
|
const isDeploy = /\b(deploy|docker|kubectl|terraform)\b/i.test(cmd);
|
|
43
80
|
return {
|
|
44
81
|
isError, isTest, isBuild, isGit, isDeploy,
|
|
@@ -92,6 +129,9 @@ export function extractErrorKeywords(cmd, response) {
|
|
|
92
129
|
*/
|
|
93
130
|
export function extractFilePaths(input) {
|
|
94
131
|
const paths = [];
|
|
132
|
+
// Direct fields (Edit/Write file_path) are kept unconditionally — an explicit edit to a
|
|
133
|
+
// /tmp path is real work the user chose to make, unlike a /tmp path that merely appears as
|
|
134
|
+
// a transient argument inside a Bash command (excluded as noise in the command branch below).
|
|
95
135
|
if (input.file_path) paths.push(input.file_path);
|
|
96
136
|
if (input.path) paths.push(input.path);
|
|
97
137
|
if (input.filePath) paths.push(input.filePath);
|
|
@@ -101,7 +141,7 @@ export function extractFilePaths(input) {
|
|
|
101
141
|
if (match) {
|
|
102
142
|
for (const m of match) {
|
|
103
143
|
const p = m.trim();
|
|
104
|
-
if (!
|
|
144
|
+
if (!isExcludedPath(p)
|
|
105
145
|
// Skip single-component paths like /exit, /clear — likely slash commands, not files
|
|
106
146
|
&& (p.indexOf('/', 1) !== -1 || /\.\w+$/.test(p))) {
|
|
107
147
|
paths.push(p);
|
package/cli/activity.mjs
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import { inferProject } from '../utils.mjs';
|
|
12
12
|
import { resolveProject } from '../project-utils.mjs';
|
|
13
|
-
import { parseArgs, out, fail } from './common.mjs';
|
|
14
|
-
import { parseIntFlag } from '../lib/cli-flags.mjs';
|
|
13
|
+
import { parseArgs, out, fail, rejectBareStringFlags } from './common.mjs';
|
|
14
|
+
import { parseIntFlag, isNumericToken } from '../lib/cli-flags.mjs';
|
|
15
15
|
|
|
16
16
|
function formatActivityResults(rows) {
|
|
17
17
|
if (!rows || rows.length === 0) return '(no events)';
|
|
@@ -31,6 +31,9 @@ export async function cmdActivity(db, args) {
|
|
|
31
31
|
const project = flags.project ? resolveProject(db, flags.project) : inferProject();
|
|
32
32
|
|
|
33
33
|
if (sub === 'save') {
|
|
34
|
+
// Reject value-less string flags before they reach saveEvent as a boolean `true`
|
|
35
|
+
// (#8470): bare --body / --title crashed with a raw "SQLite3 can only bind ..." error.
|
|
36
|
+
if (rejectBareStringFlags(flags, ['type', 'title', 'body', 'files', 'file', 'project'])) return;
|
|
34
37
|
const type = flags.type || 'observation';
|
|
35
38
|
if (!VALID_EVENT_TYPES.has(type)) {
|
|
36
39
|
fail(`[mem] activity save: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
|
|
@@ -51,7 +54,9 @@ export async function cmdActivity(db, args) {
|
|
|
51
54
|
const file_paths_merged = [...filesFromSingular, ...filesFromPlural];
|
|
52
55
|
const file_paths = file_paths_merged.length > 0 ? file_paths_merged : null;
|
|
53
56
|
const rawImp = flags.importance !== undefined ? parseInt(flags.importance, 10) : 2;
|
|
54
|
-
|
|
57
|
+
// isNumericToken first (mirrors cmdSave): bare parseInt coerces "3xyz"→3 and would
|
|
58
|
+
// persist a wrong importance that silently skews ranking. Float literals truncate (#8277).
|
|
59
|
+
if (flags.importance !== undefined && (!isNumericToken(flags.importance) || isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
|
|
55
60
|
fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
56
61
|
return;
|
|
57
62
|
}
|
|
@@ -112,7 +117,10 @@ export async function cmdActivity(db, args) {
|
|
|
112
117
|
if (row) {
|
|
113
118
|
out(JSON.stringify(row, null, 2));
|
|
114
119
|
} else {
|
|
115
|
-
|
|
120
|
+
// fail() (stderr + exit 1), matching the not-found contract of sibling commands
|
|
121
|
+
// (`get`, `activity delete`, `update`); previously stdout + exit 0, so scripts
|
|
122
|
+
// couldn't detect a missing event from the exit code.
|
|
123
|
+
fail(`[mem] activity show: event #${id} not found`);
|
|
116
124
|
}
|
|
117
125
|
return;
|
|
118
126
|
}
|
package/cli/common.mjs
CHANGED
|
@@ -54,6 +54,29 @@ export function fail(text) {
|
|
|
54
54
|
process.exitCode = 1;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Reject value-less `--flag` for string-valued flags. A bare trailing flag (or one
|
|
59
|
+
* immediately followed by another `--flag`) parses to boolean `true` (parseArgs above);
|
|
60
|
+
* that `true` then slips into code expecting a string and surfaces a raw
|
|
61
|
+
* `flags.x.split is not a function` / `SQLite3 can only bind ...` stacktrace (#8470).
|
|
62
|
+
* Returns true (and emits a clean `fail()`) when any listed key is a bare flag — the
|
|
63
|
+
* caller should `return` on true. Single source of the guard the update/registry paths
|
|
64
|
+
* previously inlined, so new string-flag commands stay consistent.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} flags Parsed flags from parseArgs.
|
|
67
|
+
* @param {string[]} keys String-valued flag names to guard (without leading dashes).
|
|
68
|
+
* @returns {boolean} true if a bare flag was found and rejected.
|
|
69
|
+
*/
|
|
70
|
+
export function rejectBareStringFlags(flags, keys) {
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
if (flags[key] === true) {
|
|
73
|
+
fail(`[mem] --${key} requires a value (received a bare flag with no value).`);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
// ─── Time Formatting ─────────────────────────────────────────────────────────
|
|
58
81
|
|
|
59
82
|
/** "just now" / "5m ago" / "3h ago" / "2d ago" relative to now. */
|
package/format-utils.mjs
CHANGED
|
@@ -9,8 +9,19 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export function truncate(str, max = 80) {
|
|
11
11
|
if (!str) return '';
|
|
12
|
+
// Defense-in-depth: a non-string (e.g. an LLM that returned title as an array/number)
|
|
13
|
+
// would throw `str.replace is not a function` and abort the caller. Coerce to '' rather
|
|
14
|
+
// than crash; the real type-guarding happens at the call site.
|
|
15
|
+
if (typeof str !== 'string') return '';
|
|
12
16
|
str = str.replace(/\n/g, ' ').trim();
|
|
13
|
-
|
|
17
|
+
if (str.length <= max) return str;
|
|
18
|
+
// Never split a UTF-16 surrogate pair: slicing between the high and low half emits a
|
|
19
|
+
// lone surrogate (invalid UTF-16) that then gets persisted to the DB. If the last kept
|
|
20
|
+
// code unit is a high surrogate, drop it so we cut on a code-point boundary.
|
|
21
|
+
let end = max - 1;
|
|
22
|
+
const last = str.charCodeAt(end - 1);
|
|
23
|
+
if (last >= 0xD800 && last <= 0xDBFF) end--;
|
|
24
|
+
return str.slice(0, end) + '\u2026';
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
/**
|
package/hook-handoff.mjs
CHANGED
|
@@ -446,13 +446,31 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
446
446
|
|
|
447
447
|
lines.push('</session-handoff>');
|
|
448
448
|
|
|
449
|
-
// Append session summary if available (long-gap enrichment)
|
|
449
|
+
// Append session summary if available (long-gap enrichment).
|
|
450
|
+
// session_summaries is keyed by the mem-internal memory_session_id, but in production
|
|
451
|
+
// session_handoffs.session_id holds the Claude Code UUID (the scope tag) — the two id
|
|
452
|
+
// namespaces never match, so the exact lookup returned nothing and this block was always
|
|
453
|
+
// dropped on a real resume. There is no bridge column (the CC-UUID lives on user_prompts,
|
|
454
|
+
// not on sdk_sessions/session_summaries), so: try the exact id match first (correct when
|
|
455
|
+
// ids align — legacy rows + tests), then fall back to the most-recent summary for the
|
|
456
|
+
// project, which at resume time is the summary from the session that wrote this handoff.
|
|
450
457
|
try {
|
|
451
|
-
|
|
458
|
+
let summary = db.prepare(`
|
|
452
459
|
SELECT completed, next_steps, remaining_items FROM session_summaries
|
|
453
460
|
WHERE memory_session_id = ? AND project = ?
|
|
454
461
|
ORDER BY created_at_epoch DESC LIMIT 1
|
|
455
462
|
`).get(handoff.session_id, project);
|
|
463
|
+
if (!summary) {
|
|
464
|
+
// Pick the project summary CLOSEST IN TIME to this handoff, not merely the newest:
|
|
465
|
+
// a handoff and its own session's summary are written within ms of each other at
|
|
466
|
+
// session end, so nearest-timestamp recovers the right session even when a different
|
|
467
|
+
// session later wrote a newer summary for the same project (concurrent/interleaved use).
|
|
468
|
+
summary = db.prepare(`
|
|
469
|
+
SELECT completed, next_steps, remaining_items FROM session_summaries
|
|
470
|
+
WHERE project = ?
|
|
471
|
+
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
472
|
+
`).get(project, handoff.created_at_epoch ?? 0);
|
|
473
|
+
}
|
|
456
474
|
if (summary && (summary.completed || summary.next_steps || summary.remaining_items)) {
|
|
457
475
|
lines.push('');
|
|
458
476
|
lines.push('<session-summary source="haiku">');
|
package/hook-llm.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
13
13
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
14
14
|
import { getVocabulary, computeVector } from './tfidf.mjs';
|
|
15
|
+
import { insertObservationRow, insertObservationFiles, insertObservationVector } from './lib/observation-write.mjs';
|
|
15
16
|
import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
16
17
|
import {
|
|
17
18
|
RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
|
|
@@ -209,48 +210,23 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
209
210
|
search_aliases: obs.searchAliases || null,
|
|
210
211
|
});
|
|
211
212
|
|
|
212
|
-
// Atomic: observation INSERT + observation_files + vector in one transaction
|
|
213
|
+
// Atomic: observation INSERT + observation_files + vector in one transaction.
|
|
214
|
+
// Column list single-sourced in lib/observation-write (shared with manual mem_save).
|
|
213
215
|
const savedId = db.transaction(() => {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
safe.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
JSON.stringify(obs.files || []),
|
|
225
|
-
obs.importance ?? 1,
|
|
226
|
-
minhashSig,
|
|
227
|
-
safe.lesson_learned,
|
|
228
|
-
safe.search_aliases,
|
|
229
|
-
getCurrentBranch(),
|
|
230
|
-
now.toISOString(), now.getTime()
|
|
231
|
-
);
|
|
232
|
-
const id = Number(result.lastInsertRowid);
|
|
233
|
-
|
|
234
|
-
// Populate observation_files junction table
|
|
235
|
-
if (id && obs.files && obs.files.length > 0) {
|
|
236
|
-
const insertFile = db.prepare('INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)');
|
|
237
|
-
for (const f of obs.files) {
|
|
238
|
-
if (typeof f === 'string' && f.length > 0) insertFile.run(id, f);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
216
|
+
const id = insertObservationRow(db, {
|
|
217
|
+
memory_session_id: sessionId, project, text: safe.text, type: obs.type,
|
|
218
|
+
title: safe.title, subtitle: safe.subtitle, narrative: safe.narrative,
|
|
219
|
+
concepts: safe.concepts, facts: safe.facts,
|
|
220
|
+
files_read: JSON.stringify(obs.filesRead || []),
|
|
221
|
+
files_modified: JSON.stringify(obs.files || []),
|
|
222
|
+
importance: obs.importance ?? 1, minhash_sig: minhashSig,
|
|
223
|
+
lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases,
|
|
224
|
+
branch: getCurrentBranch(), created_at: now.toISOString(), created_at_epoch: now.getTime(),
|
|
225
|
+
});
|
|
241
226
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (vocab) {
|
|
246
|
-
const vecText = [obs.title || '', obs.narrative || '', (Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '')].filter(Boolean).join(' ');
|
|
247
|
-
const vec = computeVector(vecText, vocab);
|
|
248
|
-
if (vec) {
|
|
249
|
-
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
250
|
-
.run(id, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
} catch (e) { debugCatch(e, 'saveObservation-vector'); }
|
|
227
|
+
insertObservationFiles(db, id, obs.files);
|
|
228
|
+
const vecText = [obs.title || '', obs.narrative || '', (Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '')].filter(Boolean).join(' ');
|
|
229
|
+
insertObservationVector(db, id, vecText);
|
|
254
230
|
|
|
255
231
|
return id;
|
|
256
232
|
})();
|
|
@@ -681,7 +657,12 @@ ${actionList}`;
|
|
|
681
657
|
releaseLLMSlot();
|
|
682
658
|
}
|
|
683
659
|
|
|
684
|
-
|
|
660
|
+
// Require a STRING title: a truthy non-string (LLM returned title as an array/number/
|
|
661
|
+
// object) would pass a bare `parsed.title` check, then crash truncate() downstream,
|
|
662
|
+
// aborting the worker before tmpFile cleanup (leak) and leaving the obs degraded.
|
|
663
|
+
if (parsed && typeof parsed.title === 'string' && parsed.title) {
|
|
664
|
+
// Normalize narrative to a string too — same non-string crash risk in truncate().
|
|
665
|
+
if (typeof parsed.narrative !== 'string') parsed.narrative = '';
|
|
685
666
|
// Discard if LLM judges observation has no learning value
|
|
686
667
|
if (parsed.importance === 0 || parsed.importance === '0') {
|
|
687
668
|
debugLog('DEBUG', 'llm-episode', `Discarded low-value observation: ${parsed.title}`);
|
package/hook-optimize.mjs
CHANGED
|
@@ -262,7 +262,7 @@ Rules:
|
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
export function applyNormalization(db, groups) {
|
|
265
|
+
export function applyNormalization(db, groups, { project = null } = {}) {
|
|
266
266
|
if (!groups || groups.length === 0) return { updated: 0 };
|
|
267
267
|
|
|
268
268
|
const aliasMap = new Map();
|
|
@@ -272,11 +272,17 @@ export function applyNormalization(db, groups) {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// Scope the mutation to `project` when normalize was scoped (v2.72.0 --project).
|
|
276
|
+
// Without this, synonym groups derived from ONE project's concepts rewrote the
|
|
277
|
+
// concepts/search_aliases of EVERY project's observations — the exact cross-project
|
|
278
|
+
// contamination the --project flag was added to prevent. NULL → all projects (legacy
|
|
279
|
+
// unscoped run), matching the search-engine `(? IS NULL OR project = ?)` idiom.
|
|
275
280
|
const rows = db.prepare(`
|
|
276
281
|
SELECT id, concepts, search_aliases FROM observations
|
|
277
282
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
278
283
|
AND concepts IS NOT NULL AND concepts != ''
|
|
279
|
-
|
|
284
|
+
AND (? IS NULL OR project = ?)
|
|
285
|
+
`).all(project, project);
|
|
280
286
|
|
|
281
287
|
let updated = 0;
|
|
282
288
|
const updateStmt = db.prepare(`
|
|
@@ -322,7 +328,7 @@ export async function executeNormalize(db, force = false, { project } = {}) {
|
|
|
322
328
|
const groups = await identifySynonymGroups(concepts);
|
|
323
329
|
if (groups.length === 0) return { processed: 0, groups: 0 };
|
|
324
330
|
|
|
325
|
-
const result = applyNormalization(db, groups);
|
|
331
|
+
const result = applyNormalization(db, groups, { project });
|
|
326
332
|
|
|
327
333
|
try { writeFileSync(NORMALIZE_GATE_FILE, JSON.stringify({ epoch: Date.now() })); } catch {}
|
|
328
334
|
|
|
@@ -340,7 +346,7 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
|
|
|
340
346
|
const cutoff = Date.now() - MERGE_TIME_WINDOW_MS;
|
|
341
347
|
const projectClause = project ? 'AND project = ?' : '';
|
|
342
348
|
const stmt = db.prepare(`
|
|
343
|
-
SELECT id, title, narrative, project, type, access_count, created_at_epoch, minhash_sig
|
|
349
|
+
SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig
|
|
344
350
|
FROM observations
|
|
345
351
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
346
352
|
AND optimized_at IS NULL
|
|
@@ -410,10 +416,19 @@ Return ONLY valid JSON:
|
|
|
410
416
|
const parsed = await callModelJSON(prompt, 'sonnet', { timeout: 20000, maxTokens: 1000 });
|
|
411
417
|
if (!parsed || !parsed.should_merge) return { merged: false };
|
|
412
418
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
419
|
+
// Keeper = highest importance, then highest access_count. Previously access_count
|
|
420
|
+
// alone, so a critical (importance=3) but never-accessed observation lost the keeper
|
|
421
|
+
// role to a trivial (importance=1) accessed one and was compressed away.
|
|
422
|
+
const keeper = cluster.reduce((best, o) => {
|
|
423
|
+
const oi = o.importance || 1, bi = best.importance || 1;
|
|
424
|
+
if (oi !== bi) return oi > bi ? o : best;
|
|
425
|
+
return (o.access_count || 0) > (best.access_count || 0) ? o : best;
|
|
426
|
+
}, cluster[0]);
|
|
416
427
|
const others = cluster.filter(o => o.id !== keeper.id);
|
|
428
|
+
// Floor the merged importance at the cluster max — merging must never silently
|
|
429
|
+
// downgrade the ranking of the most-important member (the LLM default is 2). The keeper
|
|
430
|
+
// is selected by importance-first, so keeper.importance IS the cluster max by construction.
|
|
431
|
+
const maxClusterImportance = keeper.importance || 1;
|
|
417
432
|
|
|
418
433
|
const concepts = Array.isArray(parsed.merged_concepts) ? parsed.merged_concepts.slice(0, 10) : [];
|
|
419
434
|
const facts = Array.isArray(parsed.merged_facts) ? parsed.merged_facts.slice(0, 10) : [];
|
|
@@ -428,7 +443,7 @@ Return ONLY valid JSON:
|
|
|
428
443
|
const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
|
|
429
444
|
const textField = [conceptsText, factsText, bigramText].filter(Boolean).join(' ');
|
|
430
445
|
const minhashSig = computeMinHash((title || '') + ' ' + (narrative || ''));
|
|
431
|
-
const importance = clampImportance(parsed.importance || 2);
|
|
446
|
+
const importance = Math.max(clampImportance(parsed.importance || 2), maxClusterImportance);
|
|
432
447
|
|
|
433
448
|
// Scrub LLM-output cluster-merge text fields at the UPDATE boundary.
|
|
434
449
|
// importance is numeric; minhash_sig is hash bytes.
|
package/hook-update.mjs
CHANGED
|
@@ -27,7 +27,10 @@ const STATE_DIR = DB_DIR;
|
|
|
27
27
|
const STATE_FILE = join(STATE_DIR, 'runtime', 'update-state.json');
|
|
28
28
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
29
29
|
const FETCH_TIMEOUT_MS = 3000; // 3s network timeout
|
|
30
|
-
|
|
30
|
+
// When rate-limited we got NO release data, so re-check sooner than the normal 24h
|
|
31
|
+
// cadence (GitHub's unauthenticated rate-limit window resets within the hour). 6h × ≤2
|
|
32
|
+
// requests = 4 polls/day, far under the 60/hr limit, so this is a faster retry, not a hammer.
|
|
33
|
+
const RATE_LIMIT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h retry when rate-limited
|
|
31
34
|
const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
|
|
32
35
|
|
|
33
36
|
// ── Main Entry ─────────────────────────────────────────────
|
|
@@ -57,7 +60,12 @@ export async function checkForUpdate(options = {}) {
|
|
|
57
60
|
|
|
58
61
|
const latest = await fetchLatestRelease();
|
|
59
62
|
if (!latest) {
|
|
60
|
-
|
|
63
|
+
// Re-read from disk: a 403 inside fetchWithTimeout just persisted rateLimited:true.
|
|
64
|
+
// Spreading the stale in-memory `state` (captured above with rateLimited:false) would
|
|
65
|
+
// clobber that flag back to false, so shouldCheck never honors the backoff and the
|
|
66
|
+
// rate-limit mechanism is dead. Re-reading preserves the freshly-written flag.
|
|
67
|
+
const fresh = readState();
|
|
68
|
+
saveState({ ...fresh, lastCheck: new Date().toISOString() });
|
|
61
69
|
return null;
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -174,7 +182,10 @@ async function fetchLatestRelease() {
|
|
|
174
182
|
headers,
|
|
175
183
|
);
|
|
176
184
|
if (result === 'rate-limited') return null;
|
|
177
|
-
|
|
185
|
+
// Guard tag_name: a 200-OK with a malformed body ({} / {tag_name:null}) would throw
|
|
186
|
+
// `Cannot read properties of undefined (reading 'replace')`. Caught upstream, but it
|
|
187
|
+
// poisons lastError and blocks the tags fallback below — fall through instead.
|
|
188
|
+
if (result && typeof result.tag_name === 'string') {
|
|
178
189
|
return {
|
|
179
190
|
version: result.tag_name.replace(/^v/, ''),
|
|
180
191
|
tarballUrl: result.tarball_url,
|
|
@@ -188,7 +199,7 @@ async function fetchLatestRelease() {
|
|
|
188
199
|
headers,
|
|
189
200
|
);
|
|
190
201
|
if (tags === 'rate-limited') return null;
|
|
191
|
-
if (Array.isArray(tags) && tags.length > 0) {
|
|
202
|
+
if (Array.isArray(tags) && tags.length > 0 && typeof tags[0]?.name === 'string') {
|
|
192
203
|
const tag = tags[0];
|
|
193
204
|
return {
|
|
194
205
|
version: tag.name.replace(/^v/, ''),
|
|
@@ -208,7 +219,7 @@ async function fetchWithTimeout(url, headers) {
|
|
|
208
219
|
if (res.status === 403) {
|
|
209
220
|
const state = readState();
|
|
210
221
|
saveState({ ...state, rateLimited: true });
|
|
211
|
-
debugLog('DEBUG', 'hook-update', 'GitHub API rate limited
|
|
222
|
+
debugLog('DEBUG', 'hook-update', 'GitHub API rate limited; will retry on the 6h rate-limit cadence');
|
|
212
223
|
return 'rate-limited';
|
|
213
224
|
}
|
|
214
225
|
if (!res.ok) return null;
|
package/hook.mjs
CHANGED
|
@@ -202,13 +202,20 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
|
|
|
202
202
|
// bugfix-shape nudge above and may co-fire.
|
|
203
203
|
const citeBack = loadCiteBackForEpisode(episode, RUNTIME_DIR);
|
|
204
204
|
if (citeBack) lines.push(citeBack);
|
|
205
|
+
// Trailing newline is REQUIRED: when this receipt flushes at SessionStart
|
|
206
|
+
// (leftover episode after /clear or /compact), the startup dashboard writes a
|
|
207
|
+
// second hookSpecificOutput object right after. Without the '\n' the two land
|
|
208
|
+
// back-to-back as `}{` on one line and Claude Code's line-based JSON parser
|
|
209
|
+
// drops both — losing the episode-flush / cite-back context exactly at the
|
|
210
|
+
// session boundary. Every other hookSpecificOutput write appends '\n'; this
|
|
211
|
+
// was the lone exception.
|
|
205
212
|
process.stdout.write(JSON.stringify({
|
|
206
213
|
suppressOutput: true,
|
|
207
214
|
hookSpecificOutput: {
|
|
208
215
|
hookEventName,
|
|
209
216
|
additionalContext: lines.join('\n'),
|
|
210
217
|
},
|
|
211
|
-
}));
|
|
218
|
+
}) + '\n');
|
|
212
219
|
} catch { /* never block on receipt */ }
|
|
213
220
|
}
|
|
214
221
|
} else {
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -492,6 +492,18 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
492
492
|
decay_seen_count = decay_seen_count + 1
|
|
493
493
|
WHERE id = ?
|
|
494
494
|
`);
|
|
495
|
+
// Suppressed (non-adopting) projects never demote, so uncited_streak would grow
|
|
496
|
+
// UNBOUNDED — and citeFactorClause penalizes -0.25*streak (floor 0.4), pinning every
|
|
497
|
+
// memory at the ranking floor with no recovery path. Cap at UNCITED_STREAK_THRESHOLD-1
|
|
498
|
+
// to hold the [0, threshold-1] steady state the scoring header asserts (in an adopting
|
|
499
|
+
// project the streak resets to 0 on demote, so the STORED value never exceeds 2).
|
|
500
|
+
const updateStreakCapped = db.prepare(`
|
|
501
|
+
UPDATE observations
|
|
502
|
+
SET uncited_streak = MIN(uncited_streak + 1, ?),
|
|
503
|
+
last_decided_session_id = ?,
|
|
504
|
+
decay_seen_count = decay_seen_count + 1
|
|
505
|
+
WHERE id = ?
|
|
506
|
+
`);
|
|
495
507
|
const updateDemote = db.prepare(`
|
|
496
508
|
UPDATE observations
|
|
497
509
|
SET importance = MAX(?, importance - 1),
|
|
@@ -520,6 +532,9 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
520
532
|
if (nextStreak >= UNCITED_STREAK_THRESHOLD && !suppressDemotion) {
|
|
521
533
|
updateDemote.run(IMPORTANCE_FLOOR, sessionId, Date.now(), id);
|
|
522
534
|
demoted++;
|
|
535
|
+
} else if (suppressDemotion) {
|
|
536
|
+
// Never-demoting project: cap the streak so cite_factor can't sink to floor.
|
|
537
|
+
updateStreakCapped.run(UNCITED_STREAK_THRESHOLD - 1, sessionId, id);
|
|
523
538
|
} else {
|
|
524
539
|
updateStreakOnly.run(sessionId, id);
|
|
525
540
|
}
|