@pcircle/memesh 4.2.7 → 4.2.9
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/README.de.md +45 -2
- package/README.es.md +45 -2
- package/README.fr.md +45 -2
- package/README.ja.md +45 -2
- package/README.ko.md +45 -2
- package/README.md +28 -14
- package/README.pt.md +45 -2
- package/README.th.md +16 -2
- package/README.vi.md +45 -2
- package/README.zh-CN.md +44 -2
- package/README.zh-TW.md +44 -2
- package/dashboard/dist/index.html +5 -5
- package/dist/cli/view-live.d.ts.map +1 -1
- package/dist/cli/view-live.js +3 -1
- package/dist/cli/view-live.js.map +1 -1
- package/dist/core/analytics.d.ts +0 -31
- package/dist/core/analytics.d.ts.map +1 -1
- package/dist/core/analytics.js +0 -59
- package/dist/core/analytics.js.map +1 -1
- package/dist/core/config.d.ts +0 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +25 -4
- package/dist/core/config.js.map +1 -1
- package/dist/core/digest-validator.d.ts +1 -1
- package/dist/core/digest-validator.d.ts.map +1 -1
- package/dist/core/digest-validator.js +7 -2
- package/dist/core/digest-validator.js.map +1 -1
- package/dist/core/doctor.d.ts +5 -0
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +87 -5
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/dreamer.d.ts.map +1 -1
- package/dist/core/dreamer.js.map +1 -1
- package/dist/core/embedder.d.ts +1 -0
- package/dist/core/embedder.d.ts.map +1 -1
- package/dist/core/embedder.js +14 -2
- package/dist/core/embedder.js.map +1 -1
- package/dist/core/extractor.d.ts.map +1 -1
- package/dist/core/extractor.js +10 -1
- package/dist/core/extractor.js.map +1 -1
- package/dist/core/failure-analyzer.d.ts.map +1 -1
- package/dist/core/failure-analyzer.js +16 -2
- package/dist/core/failure-analyzer.js.map +1 -1
- package/dist/core/llm-telemetry.d.ts +12 -0
- package/dist/core/llm-telemetry.d.ts.map +1 -1
- package/dist/core/llm-telemetry.js +21 -3
- package/dist/core/llm-telemetry.js.map +1 -1
- package/dist/core/operations.d.ts +5 -0
- package/dist/core/operations.d.ts.map +1 -1
- package/dist/core/operations.js +16 -0
- package/dist/core/operations.js.map +1 -1
- package/dist/core/paths.d.ts +2 -0
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js +43 -0
- package/dist/core/paths.js.map +1 -1
- package/dist/core/project-tags.d.ts +20 -0
- package/dist/core/project-tags.d.ts.map +1 -0
- package/dist/core/project-tags.js +42 -0
- package/dist/core/project-tags.js.map +1 -0
- package/dist/core/schema-export.d.ts.map +1 -1
- package/dist/core/schema-export.js +17 -1
- package/dist/core/schema-export.js.map +1 -1
- package/dist/core/skill-usage-log.d.ts +1 -1
- package/dist/core/skill-usage-log.d.ts.map +1 -1
- package/dist/core/skill-usage-log.js +2 -2
- package/dist/core/skill-usage-log.js.map +1 -1
- package/dist/core/verifier.d.ts.map +1 -1
- package/dist/core/verifier.js +1 -6
- package/dist/core/verifier.js.map +1 -1
- package/dist/skills-manifest.json +12 -12
- package/dist/transports/cli/cli.js +132 -5
- package/dist/transports/cli/cli.js.map +1 -1
- package/dist/transports/http/server.d.ts.map +1 -1
- package/dist/transports/http/server.js +12 -20
- package/dist/transports/http/server.js.map +1 -1
- package/package.json +1 -1
- package/scripts/hooks/_shared.js +143 -3
- package/scripts/hooks/post-commit.js +25 -46
- package/scripts/hooks/pre-compact.js +41 -40
- package/scripts/hooks/session-start.js +168 -17
- package/scripts/hooks/session-summary.js +145 -33
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
import { createRequire } from 'module';
|
|
4
4
|
import { spawn } from 'child_process';
|
|
5
|
-
import { createHash } from 'crypto';
|
|
6
5
|
import { homedir } from 'os';
|
|
7
6
|
import { join } from 'path';
|
|
8
7
|
import { pathToFileURL } from 'url';
|
|
9
8
|
import { existsSync, readFileSync, unlinkSync, rmSync, appendFileSync, chmodSync } from 'fs';
|
|
10
9
|
import {
|
|
10
|
+
buildReferenceContext,
|
|
11
11
|
ensurePrivateDir,
|
|
12
12
|
getDbPath,
|
|
13
13
|
getMemeshDirFromDbPath,
|
|
14
14
|
getProjectName,
|
|
15
|
+
importFromPluginRoot,
|
|
15
16
|
isAgenticOrchestrationEnabled,
|
|
16
17
|
isTrustedForAutoContext,
|
|
17
18
|
readUpdateCheckCache,
|
|
@@ -373,7 +374,11 @@ function spawnFreshUpdateCheck(installedVersion) {
|
|
|
373
374
|
{
|
|
374
375
|
detached: true,
|
|
375
376
|
stdio: 'ignore',
|
|
376
|
-
|
|
377
|
+
// `memesh status` already forces a fresh npm lookup (getUpdateCheck
|
|
378
|
+
// with preferFresh, the default) and rewrites the cache — so the
|
|
379
|
+
// spawn itself is the refresh. An earlier MEMESH_UPDATE_REFRESH='1'
|
|
380
|
+
// env var here had NO reader anywhere and did nothing; removed.
|
|
381
|
+
env: { ...process.env },
|
|
377
382
|
windowsHide: true,
|
|
378
383
|
},
|
|
379
384
|
);
|
|
@@ -627,9 +632,10 @@ process.stdin.on('end', async () => {
|
|
|
627
632
|
// throw `no such column: status`, hiding lessons from session-start
|
|
628
633
|
// auto-context indefinitely.
|
|
629
634
|
let lessonCount = 0;
|
|
635
|
+
let lessonEntities = [];
|
|
630
636
|
try {
|
|
631
637
|
const lessonRows = db.prepare(`
|
|
632
|
-
SELECT DISTINCT e.id, e.metadata
|
|
638
|
+
SELECT DISTINCT e.id, e.name, e.type, e.metadata
|
|
633
639
|
FROM entities e
|
|
634
640
|
JOIN tags t ON t.entity_id = e.id
|
|
635
641
|
WHERE e.type = 'lesson_learned'
|
|
@@ -638,6 +644,7 @@ process.stdin.on('end', async () => {
|
|
|
638
644
|
LIMIT 50
|
|
639
645
|
`).all(projectTag).filter(entity => isTrustedForAutoContext(entity.metadata));
|
|
640
646
|
lessonCount = lessonRows.length;
|
|
647
|
+
lessonEntities = lessonRows;
|
|
641
648
|
} catch (err) {
|
|
642
649
|
// Real query bug (typo, missing column on a schema older than v2.11)
|
|
643
650
|
// — surface to stderr so a maintainer sees it on next session.
|
|
@@ -668,12 +675,126 @@ process.stdin.on('end', async () => {
|
|
|
668
675
|
summary = parts.join(' · ');
|
|
669
676
|
}
|
|
670
677
|
|
|
678
|
+
// --- Build the context actually injected into the model ---------
|
|
679
|
+
// The `summary` above is a human banner (counts only) and never
|
|
680
|
+
// reaches the model. This block is the real payload: the top-ranked
|
|
681
|
+
// entities with a short observation snippet each, sent via
|
|
682
|
+
// hookSpecificOutput.additionalContext.
|
|
683
|
+
//
|
|
684
|
+
// Lessons come first — they are the "don't repeat this mistake"
|
|
685
|
+
// signal and are the most expensive thing to rediscover.
|
|
686
|
+
//
|
|
687
|
+
// Budget: additionalContext is capped by Claude Code (10k chars). We
|
|
688
|
+
// stay far under that on purpose — session start should prime the
|
|
689
|
+
// model, not consume its working context. Snippets are truncated per
|
|
690
|
+
// observation and the whole block is hard-capped.
|
|
691
|
+
const MAX_SNIPPET = 160;
|
|
692
|
+
const MAX_CONTEXT_CHARS = 4000;
|
|
693
|
+
|
|
694
|
+
const memoryLines = [];
|
|
695
|
+
try {
|
|
696
|
+
// Only the entities we will actually render — the lesson query pulls
|
|
697
|
+
// up to 50 rows for the banner count, but at most 5 are injected, and
|
|
698
|
+
// this runs before the user's first turn. Bounded well under SQLite's
|
|
699
|
+
// 999-variable limit by construction (5 lessons + sessionLimit
|
|
700
|
+
// project + 5 recent).
|
|
701
|
+
const topLessons = lessonEntities.slice(0, 5);
|
|
702
|
+
const rankedIds = [
|
|
703
|
+
...topLessons.map(e => e.id),
|
|
704
|
+
...projectEntities.map(e => e.id),
|
|
705
|
+
...recentEntities.map(e => e.id),
|
|
706
|
+
];
|
|
707
|
+
const uniqueIds = [...new Set(rankedIds)];
|
|
708
|
+
|
|
709
|
+
// One query for every snippet — avoids N round-trips on the
|
|
710
|
+
// session-start hot path (this runs before the user's first turn).
|
|
711
|
+
const snippets = new Map();
|
|
712
|
+
if (uniqueIds.length > 0) {
|
|
713
|
+
const placeholders = uniqueIds.map(() => '?').join(',');
|
|
714
|
+
const obsRows = db.prepare(
|
|
715
|
+
`SELECT entity_id, content FROM observations
|
|
716
|
+
WHERE entity_id IN (${placeholders})
|
|
717
|
+
ORDER BY id ASC`
|
|
718
|
+
).all(...uniqueIds);
|
|
719
|
+
for (const row of obsRows) {
|
|
720
|
+
// Keep the FIRST observation per entity: observations are
|
|
721
|
+
// append-only, so the first one is the defining statement and
|
|
722
|
+
// later ones are refinements.
|
|
723
|
+
if (snippets.has(row.entity_id)) continue;
|
|
724
|
+
const text = String(row.content ?? '').replace(/\s+/g, ' ').trim();
|
|
725
|
+
if (text) snippets.set(row.entity_id, text.slice(0, MAX_SNIPPET));
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Groups overlap by construction: a lesson tagged to this project
|
|
730
|
+
// is in lessonEntities AND projectEntities. Render each entity once,
|
|
731
|
+
// in the highest-priority group it belongs to, so the injected block
|
|
732
|
+
// doesn't spend the model's context repeating itself.
|
|
733
|
+
const rendered = new Set();
|
|
734
|
+
const renderGroup = (label, entities) => {
|
|
735
|
+
const fresh = entities.filter(e => !rendered.has(e.id));
|
|
736
|
+
if (fresh.length === 0) return;
|
|
737
|
+
memoryLines.push(label);
|
|
738
|
+
for (const e of fresh) {
|
|
739
|
+
rendered.add(e.id);
|
|
740
|
+
const snippet = snippets.get(e.id);
|
|
741
|
+
const type = e.type || 'memory';
|
|
742
|
+
memoryLines.push(
|
|
743
|
+
snippet ? `- ${e.name} (${type}): ${snippet}` : `- ${e.name} (${type})`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
memoryLines.push('');
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
renderGroup('Lessons learned (avoid repeating these):', topLessons);
|
|
750
|
+
renderGroup(`Project memory for "${projectName}":`, projectEntities);
|
|
751
|
+
renderGroup('Recently active across projects:', recentEntities);
|
|
752
|
+
} catch (err) {
|
|
753
|
+
// Snippet enrichment is best-effort. A failure here must not stop
|
|
754
|
+
// the banner or the session — but trace it, because a silent break
|
|
755
|
+
// means memories stop reaching the model again (the exact v4.2.7
|
|
756
|
+
// regression this block was written to fix).
|
|
757
|
+
try { process.stderr.write(`[memesh session-start] memory-context: ${err?.message || err}\n`); } catch {}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
let memoryContext = '';
|
|
761
|
+
if (memoryLines.length > 0) {
|
|
762
|
+
// Same wrapper pre-edit-recall uses: an explicit "background data,
|
|
763
|
+
// not instructions" preamble plus a fenced block. Memory content is
|
|
764
|
+
// attacker-influenced in the general case (anything the agent has
|
|
765
|
+
// ever been told can end up in an observation), so it must be
|
|
766
|
+
// delimited the same way on every injection path — not hand-rolled
|
|
767
|
+
// per hook.
|
|
768
|
+
//
|
|
769
|
+
// Truncate the LINES before wrapping, so the closing fence is never
|
|
770
|
+
// cut off — a dangling fence would let the tail of the block escape
|
|
771
|
+
// its delimiter.
|
|
772
|
+
const budgeted = [];
|
|
773
|
+
let used = 0;
|
|
774
|
+
for (const line of memoryLines) {
|
|
775
|
+
if (used + line.length + 1 > MAX_CONTEXT_CHARS) {
|
|
776
|
+
budgeted.push('… (truncated)');
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
budgeted.push(line);
|
|
780
|
+
used += line.length + 1;
|
|
781
|
+
}
|
|
782
|
+
memoryContext = buildReferenceContext(budgeted);
|
|
783
|
+
}
|
|
784
|
+
|
|
671
785
|
// --- Record injected entity IDs for recall effectiveness tracking ---
|
|
672
|
-
// The hit/miss
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
786
|
+
// The Stop hook decides hit/miss by removing the transcript records
|
|
787
|
+
// Claude Code created FROM this hook's output (see stripHookEchoes in
|
|
788
|
+
// session-summary.js) and then looking for the entity name in what
|
|
789
|
+
// remains. `injectedContext` is kept as the record of what was shown,
|
|
790
|
+
// not as a string to subtract — an earlier version subtracted it and a
|
|
791
|
+
// later one counted its occurrences, and BOTH were wrong because one
|
|
792
|
+
// injection is echoed into the transcript more than once.
|
|
793
|
+
//
|
|
794
|
+
// It must still be the text we actually injected: previously it was the
|
|
795
|
+
// count-only banner, so every injected entity was scored against a
|
|
796
|
+
// transcript it had never appeared in and took a `recall_miss` it did
|
|
797
|
+
// not earn.
|
|
677
798
|
try {
|
|
678
799
|
const seenIds = new Set();
|
|
679
800
|
const allInjected = [...projectEntities, ...recentEntities].filter(e => {
|
|
@@ -694,7 +815,7 @@ process.stdin.on('end', async () => {
|
|
|
694
815
|
project: projectName,
|
|
695
816
|
entityIds: allInjected.map(e => e.id),
|
|
696
817
|
entityNames: allInjected.map(e => e.name),
|
|
697
|
-
injectedContext: summary,
|
|
818
|
+
injectedContext: memoryContext || summary,
|
|
698
819
|
}
|
|
699
820
|
);
|
|
700
821
|
|
|
@@ -727,12 +848,12 @@ process.stdin.on('end', async () => {
|
|
|
727
848
|
summary += '\n[AO opt-in: dispatch verifiable work as background agent · skill: agentic-orchestration]';
|
|
728
849
|
try {
|
|
729
850
|
const usagePath = join(homedir(), '.memesh', 'skill-usage.jsonl');
|
|
730
|
-
|
|
731
|
-
|
|
851
|
+
// Only { ts, event } — an earlier `payload: { cwd_hashed }` was never
|
|
852
|
+
// read by summariseSkillUsage (counts by event name only), so it was
|
|
853
|
+
// write-only privacy-adjacent data. Removed.
|
|
732
854
|
const line = JSON.stringify({
|
|
733
855
|
ts: new Date().toISOString(),
|
|
734
856
|
event: 'agentic_orchestration_banner_injected',
|
|
735
|
-
payload: { cwd_hashed: cwdHashed },
|
|
736
857
|
}) + '\n';
|
|
737
858
|
appendFileSync(usagePath, line);
|
|
738
859
|
try { chmodSync(usagePath, 0o600); } catch { /* non-POSIX */ }
|
|
@@ -768,7 +889,7 @@ process.stdin.on('end', async () => {
|
|
|
768
889
|
? [...bannerLines.filter(l => l.length > 0), '', summary].join('\n')
|
|
769
890
|
: summary;
|
|
770
891
|
|
|
771
|
-
output(finalMessage);
|
|
892
|
+
output(finalMessage, memoryContext);
|
|
772
893
|
} finally {
|
|
773
894
|
db.close();
|
|
774
895
|
}
|
|
@@ -779,8 +900,8 @@ process.stdin.on('end', async () => {
|
|
|
779
900
|
// F5: derive pluginRoot strictly from this file's location.
|
|
780
901
|
// See `resolvePluginRoot` for the full reasoning.
|
|
781
902
|
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
782
|
-
const dbMod = await
|
|
783
|
-
const lifecycleMod = await
|
|
903
|
+
const dbMod = await importFromPluginRoot(pluginRoot, 'dist/db.js');
|
|
904
|
+
const lifecycleMod = await importFromPluginRoot(pluginRoot, 'dist/core/lifecycle.js');
|
|
784
905
|
dbMod.openDatabase();
|
|
785
906
|
try {
|
|
786
907
|
lifecycleMod.compressWeeklyNoise(dbMod.getDatabase());
|
|
@@ -813,6 +934,36 @@ process.stdin.on('end', async () => {
|
|
|
813
934
|
}
|
|
814
935
|
});
|
|
815
936
|
|
|
816
|
-
|
|
817
|
-
|
|
937
|
+
/**
|
|
938
|
+
* Emit the SessionStart hook payload.
|
|
939
|
+
*
|
|
940
|
+
* Two channels, two audiences — they are NOT interchangeable:
|
|
941
|
+
*
|
|
942
|
+
* systemMessage -> shown to the human in the terminal. Claude Code
|
|
943
|
+
* strips it from the model's context entirely
|
|
944
|
+
* (`normalizeAttachmentForAPI` returns [] for the
|
|
945
|
+
* `hook_system_message` attachment type).
|
|
946
|
+
* hookSpecificOutput -> `additionalContext` IS injected into the model's
|
|
947
|
+
* .additionalContext context for the next turn. `SessionStart` is one of
|
|
948
|
+
* the nine events with a valid variant.
|
|
949
|
+
*
|
|
950
|
+
* Until v4.2.7 this hook only ever emitted `systemMessage`, so *nothing*
|
|
951
|
+
* memesh recalled at session start ever reached the model — the banner said
|
|
952
|
+
* "4 project + 5 recent memories" while the model received none of them.
|
|
953
|
+
* Worse, the Stop hook then marked every one of those entities as a
|
|
954
|
+
* `recall_miss` for not appearing in the transcript, so memories that were
|
|
955
|
+
* never shown were permanently penalised in ranking (see scoring.ts
|
|
956
|
+
* impactScore). Passing `memoryContext` closes that loop honestly.
|
|
957
|
+
*
|
|
958
|
+
* The shape is asserted by tests/helpers/hook-output-contract.ts.
|
|
959
|
+
*/
|
|
960
|
+
function output(text, memoryContext) {
|
|
961
|
+
const payload = { systemMessage: text };
|
|
962
|
+
if (memoryContext) {
|
|
963
|
+
payload.hookSpecificOutput = {
|
|
964
|
+
hookEventName: 'SessionStart',
|
|
965
|
+
additionalContext: memoryContext,
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
console.log(JSON.stringify(payload));
|
|
818
969
|
}
|
|
@@ -11,9 +11,11 @@ import { spawn } from 'child_process';
|
|
|
11
11
|
import os from 'os';
|
|
12
12
|
import { pathToFileURL } from 'url';
|
|
13
13
|
import {
|
|
14
|
+
captureEntity,
|
|
14
15
|
decideAutoUpdateHook,
|
|
15
16
|
getMemeshDirFromDbPath,
|
|
16
17
|
getProjectName,
|
|
18
|
+
importFromPluginRoot,
|
|
17
19
|
isAutoCaptureEnabled,
|
|
18
20
|
openHookDb,
|
|
19
21
|
readUpdateCheckCache,
|
|
@@ -125,11 +127,23 @@ function parseTranscript(transcriptPath) {
|
|
|
125
127
|
// the prior version) that signalled review fatigue more than
|
|
126
128
|
// working logic.
|
|
127
129
|
} catch {
|
|
128
|
-
// Skip malformed JSONL lines
|
|
130
|
+
// Skip malformed JSONL lines — benign, per-line, deliberately not traced.
|
|
129
131
|
}
|
|
130
132
|
}
|
|
131
|
-
} catch {
|
|
132
|
-
//
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// The transcript file itself could not be read, which empties this
|
|
135
|
+
// session's entire capture — filesEdited/errors/toolCallCount all return
|
|
136
|
+
// zero, so downstream `toolCallCount < 3` bails and no session insight,
|
|
137
|
+
// failure analysis or lesson is produced. An absent file is the normal
|
|
138
|
+
// "not written yet" case; anything else is a real fault worth a trace.
|
|
139
|
+
if (err?.code !== 'ENOENT') {
|
|
140
|
+
try {
|
|
141
|
+
process.stderr.write(
|
|
142
|
+
`[memesh session-summary] transcript ${transcriptPath} unreadable ` +
|
|
143
|
+
`(${err?.message || err}); session capture skipped this run.\n`,
|
|
144
|
+
);
|
|
145
|
+
} catch { /* stderr must never throw */ }
|
|
146
|
+
}
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
return { filesEdited: [...filesEdited], bashCommands, errorsEncountered, toolCallCount };
|
|
@@ -163,7 +177,6 @@ process.stdin.on('end', async () => {
|
|
|
163
177
|
const sessionId = inputData.session_id || 'unknown';
|
|
164
178
|
const transcriptPath = inputData.transcript_path;
|
|
165
179
|
const cwd = inputData.cwd || process.cwd();
|
|
166
|
-
const stopReason = inputData.stop_reason || 'unknown';
|
|
167
180
|
// Default-allow: when Claude Code's Stop payload omits
|
|
168
181
|
// `was_in_agentic_loop` (it has been silently absent in production
|
|
169
182
|
// for an unknown number of releases — symptom: zero session-insight
|
|
@@ -173,8 +186,18 @@ process.stdin.on('end', async () => {
|
|
|
173
186
|
// (default-deny) and the hook silently never captured anything.
|
|
174
187
|
const wasAgenticLoop = inputData.was_in_agentic_loop !== false;
|
|
175
188
|
|
|
176
|
-
// Guards: skip low-signal sessions
|
|
177
|
-
|
|
189
|
+
// Guards: skip low-signal sessions.
|
|
190
|
+
//
|
|
191
|
+
// A `stop_reason === 'user_interrupt'` guard used to live here, but
|
|
192
|
+
// Claude Code's Stop payload carries no `stop_reason` field — verified
|
|
193
|
+
// against the shipped cli.js bundle, whose Stop input is
|
|
194
|
+
// `{...base, hook_event_name:"Stop", stop_hook_active}` with no such key
|
|
195
|
+
// (the `stop_reason` that appears in the bundle is the Anthropic API
|
|
196
|
+
// message field, not a hook input). So the guard read `undefined`, was
|
|
197
|
+
// always false, and never skipped anything — a filter that looked active
|
|
198
|
+
// but did nothing, the exact sibling of the `was_in_agentic_loop` absence
|
|
199
|
+
// above. Removed; the `toolCallCount < 3` check below is the real
|
|
200
|
+
// low-signal filter.
|
|
178
201
|
if (!wasAgenticLoop) return exit0();
|
|
179
202
|
// Trace why we're skipping. Two failure modes:
|
|
180
203
|
// (a) transcript_path absent — schema flip, Claude Code stopped
|
|
@@ -211,7 +234,9 @@ process.stdin.on('end', async () => {
|
|
|
211
234
|
// Open DB via shared helper — applies SCHEMA_SQL + status migration.
|
|
212
235
|
// sqlite-vec is loaded separately because only this hook needs it
|
|
213
236
|
// (for embedding-aware recall-effectiveness tracking).
|
|
214
|
-
|
|
237
|
+
// { fts: true } guarantees the entities_fts table exists so captureEntity()
|
|
238
|
+
// can keep it in sync — session-insight memories must be FTS-recallable.
|
|
239
|
+
const handle = openHookDb(process.env, { fts: true });
|
|
215
240
|
if (!handle) {
|
|
216
241
|
// Native module unavailable (plugin-marketplace cache install with no
|
|
217
242
|
// node_modules). Skip session-capture work, but still let the
|
|
@@ -251,17 +276,30 @@ process.stdin.on('end', async () => {
|
|
|
251
276
|
// Build and store session memories
|
|
252
277
|
const baseTags = ['source:auto-capture', `session:${sessionId}`, `project:${projectName}`];
|
|
253
278
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
279
|
+
// Producer for pre-edit-recall's Strategy 1 (`file:<name>` tag lookup).
|
|
280
|
+
// That read path queries both the full basename and the extension-less
|
|
281
|
+
// form (`file:auth.ts` OR `file:auth`), but nothing ever WROTE these
|
|
282
|
+
// tags — on every real DB the query returned zero rows and the strategy
|
|
283
|
+
// was dead. Emitting both forms here lights it up: a memory captured while
|
|
284
|
+
// editing a file becomes findable the next time that file is edited.
|
|
285
|
+
// filesEdited already holds basenames (see parseTranscript).
|
|
286
|
+
function fileTagsFor(files) {
|
|
287
|
+
const tags = new Set();
|
|
288
|
+
for (const f of files) {
|
|
289
|
+
if (!f) continue;
|
|
290
|
+
tags.add(`file:${f}`);
|
|
291
|
+
const noExt = f.replace(/\.[^.]+$/, '');
|
|
292
|
+
if (noExt && noExt !== f) tags.add(`file:${noExt}`);
|
|
293
|
+
}
|
|
294
|
+
return [...tags];
|
|
295
|
+
}
|
|
258
296
|
|
|
297
|
+
// Delegate the write to the shared captureEntity() so entities land in
|
|
298
|
+
// entities_fts too. This copy used to insert entity + observations + tags
|
|
299
|
+
// only, skipping the FTS reindex the sibling hooks did — which left every
|
|
300
|
+
// session-insight memory unrecallable via the FTS keyword path.
|
|
259
301
|
function storeMemory(name, type, observations, tags) {
|
|
260
|
-
|
|
261
|
-
const row = selectEntity.get(name);
|
|
262
|
-
if (!row) return;
|
|
263
|
-
for (const obs of observations) insertObs.run(row.id, obs);
|
|
264
|
-
for (const tag of tags) insertTag.run(row.id, tag);
|
|
302
|
+
captureEntity(db, { name, type, observations, tags });
|
|
265
303
|
}
|
|
266
304
|
|
|
267
305
|
// Rule 1: File editing session summary
|
|
@@ -273,7 +311,7 @@ process.stdin.on('end', async () => {
|
|
|
273
311
|
`Session edited ${filesEdited.length} file(s): ${filesEdited.join(', ')}`,
|
|
274
312
|
`Total tool calls: ${toolCallCount}`,
|
|
275
313
|
],
|
|
276
|
-
baseTags
|
|
314
|
+
[...baseTags, ...fileTagsFor(filesEdited)]
|
|
277
315
|
);
|
|
278
316
|
}
|
|
279
317
|
|
|
@@ -286,7 +324,7 @@ process.stdin.on('end', async () => {
|
|
|
286
324
|
`Fixed ${errorsEncountered.length} error(s) by editing ${filesEdited.join(', ')}`,
|
|
287
325
|
...errorsEncountered.slice(0, 3).map(e => `Error: ${e.slice(0, 100)}`),
|
|
288
326
|
],
|
|
289
|
-
[...baseTags, 'type:bugfix']
|
|
327
|
+
[...baseTags, 'type:bugfix', ...fileTagsFor(filesEdited)]
|
|
290
328
|
);
|
|
291
329
|
}
|
|
292
330
|
|
|
@@ -366,15 +404,16 @@ process.stdin.on('end', async () => {
|
|
|
366
404
|
// Check if recall_hits column exists (v4.0+ migration)
|
|
367
405
|
const colCheck = db.prepare("PRAGMA table_info(entities)").all();
|
|
368
406
|
if (colCheck.some(c => c.name === 'recall_hits')) {
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
407
|
+
// Drop the records Claude Code created FROM our own hook
|
|
408
|
+
// output before matching. One SessionStart injection lands in
|
|
409
|
+
// the transcript 2+ times (hook_success + hook_additional_context),
|
|
410
|
+
// so any count-based discount depends on guessing an
|
|
411
|
+
// undocumented internal — get it wrong and every entity scores
|
|
412
|
+
// a hit instead of a miss. Structural removal is copy-count
|
|
413
|
+
// and encoding independent.
|
|
414
|
+
const sessionText = stripHookEchoes(readFileSync(transcriptPath, 'utf8')).toLowerCase();
|
|
415
|
+
|
|
416
|
+
// Hit/miss decision lives in `isRecallHit` (exported, unit-tested).
|
|
378
417
|
|
|
379
418
|
const updateHit = db.prepare(
|
|
380
419
|
'UPDATE entities SET recall_hits = COALESCE(recall_hits, 0) + 1 WHERE id = ?'
|
|
@@ -387,7 +426,7 @@ process.stdin.on('end', async () => {
|
|
|
387
426
|
const name = (entityNames[i] || '').toLowerCase();
|
|
388
427
|
// Skip very short names to avoid false positives
|
|
389
428
|
if (name.length < 4) continue;
|
|
390
|
-
if (
|
|
429
|
+
if (isRecallHit(sessionText, name)) {
|
|
391
430
|
updateHit.run(entityIds[i]);
|
|
392
431
|
} else {
|
|
393
432
|
updateMiss.run(entityIds[i]);
|
|
@@ -417,13 +456,13 @@ process.stdin.on('end', async () => {
|
|
|
417
456
|
// F5: derive pluginRoot strictly from this file's location.
|
|
418
457
|
// See `resolvePluginRoot` for the full reasoning.
|
|
419
458
|
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
420
|
-
const configMod = await
|
|
459
|
+
const configMod = await importFromPluginRoot(pluginRoot, 'dist/core/config.js');
|
|
421
460
|
const config = configMod.readConfig();
|
|
422
461
|
|
|
423
462
|
if (config.llm) {
|
|
424
|
-
const { openDatabase, closeDatabase } = await
|
|
425
|
-
const { analyzeFailure } = await
|
|
426
|
-
const { createLesson } = await
|
|
463
|
+
const { openDatabase, closeDatabase } = await importFromPluginRoot(pluginRoot, 'dist/db.js');
|
|
464
|
+
const { analyzeFailure } = await importFromPluginRoot(pluginRoot, 'dist/core/failure-analyzer.js');
|
|
465
|
+
const { createLesson } = await importFromPluginRoot(pluginRoot, 'dist/core/lesson-engine.js');
|
|
427
466
|
|
|
428
467
|
openDatabase();
|
|
429
468
|
try {
|
|
@@ -452,7 +491,7 @@ process.stdin.on('end', async () => {
|
|
|
452
491
|
// logic and dream-history.json schema.
|
|
453
492
|
try {
|
|
454
493
|
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
455
|
-
const configMod = await
|
|
494
|
+
const configMod = await importFromPluginRoot(pluginRoot, 'dist/core/config.js');
|
|
456
495
|
const config = configMod.readConfig();
|
|
457
496
|
maybeTriggerDream(projectName, config, pluginRoot);
|
|
458
497
|
} catch (dreamErr) {
|
|
@@ -647,6 +686,79 @@ function countEpisodicEntities(projectName) {
|
|
|
647
686
|
* the detached background runner. Pure side effect — no return value
|
|
648
687
|
* used by callers.
|
|
649
688
|
*/
|
|
689
|
+
/**
|
|
690
|
+
* Attachment record types Claude Code uses to persist a hook's own output
|
|
691
|
+
* into the transcript. Anything memesh injected reaches the transcript
|
|
692
|
+
* through one of these, so they must be removed before asking "did the
|
|
693
|
+
* session reference this memory?".
|
|
694
|
+
*
|
|
695
|
+
* Verified against Claude Code v2.1.19: ONE SessionStart injection lands in
|
|
696
|
+
* the transcript at least twice — once as `hook_success` (carrying the raw
|
|
697
|
+
* hook stdout) and once as `hook_additional_context` (the parsed payload).
|
|
698
|
+
*/
|
|
699
|
+
const HOOK_ECHO_ATTACHMENT_TYPES = new Set([
|
|
700
|
+
'hook_success',
|
|
701
|
+
'hook_additional_context',
|
|
702
|
+
'hook_system_message',
|
|
703
|
+
]);
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Remove memesh's own injected text from a raw JSONL transcript.
|
|
707
|
+
*
|
|
708
|
+
* Counting occurrences and subtracting the injected copies does NOT work:
|
|
709
|
+
* it depends on knowing exactly how many times Claude Code echoes a hook
|
|
710
|
+
* payload, which is an undocumented internal that has already been observed
|
|
711
|
+
* at 2+ copies (and 16 in one real transcript). Guessing that constant is
|
|
712
|
+
* how "every entity is a miss" becomes "every entity is a hit" — equally
|
|
713
|
+
* useless, and invisible to a hand-built test fixture.
|
|
714
|
+
*
|
|
715
|
+
* Dropping the hook-echo records structurally is independent of both the
|
|
716
|
+
* copy count and the JSON escaping.
|
|
717
|
+
*/
|
|
718
|
+
export function stripHookEchoes(rawTranscript) {
|
|
719
|
+
const kept = [];
|
|
720
|
+
for (const line of String(rawTranscript ?? '').split('\n')) {
|
|
721
|
+
if (!line.trim()) continue;
|
|
722
|
+
let entry;
|
|
723
|
+
try {
|
|
724
|
+
entry = JSON.parse(line);
|
|
725
|
+
} catch {
|
|
726
|
+
// Unparseable line: keep it. Losing a line can only cause a false
|
|
727
|
+
// MISS (we under-count references), which is the safe direction —
|
|
728
|
+
// it never manufactures a hit the session did not earn.
|
|
729
|
+
kept.push(line);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
const type = entry?.attachment?.type ?? entry?.type;
|
|
733
|
+
if (typeof type === 'string' && HOOK_ECHO_ATTACHMENT_TYPES.has(type)) continue;
|
|
734
|
+
kept.push(line);
|
|
735
|
+
}
|
|
736
|
+
return kept.join('\n');
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Did the session actually USE the memory named `name`, or does the name only
|
|
741
|
+
* appear because memesh injected it at session start?
|
|
742
|
+
*
|
|
743
|
+
* The caller passes `sessionText` with memesh's own SessionStart injection
|
|
744
|
+
* already stripped structurally (see `stripHookEchoes` — matches on
|
|
745
|
+
* `attachment.type`, so it is independent of JSON escaping and of how many
|
|
746
|
+
* times Claude Code echoes one injection). That removal is what stops an
|
|
747
|
+
* injected name from scoring a false hit; once the echo is gone, a plain
|
|
748
|
+
* substring match is the whole test.
|
|
749
|
+
*
|
|
750
|
+
* (This replaced an earlier `transcript.replace(injectedBlob, '')` + match,
|
|
751
|
+
* which silently failed on JSON-encoded transcripts and scored every entity a
|
|
752
|
+
* hit — see the callsite comment.)
|
|
753
|
+
*
|
|
754
|
+
* Self-contained for its unit tests: lowercases both sides and ignores names
|
|
755
|
+
* shorter than 4 chars (too generic to match reliably).
|
|
756
|
+
*/
|
|
757
|
+
export function isRecallHit(sessionText, name) {
|
|
758
|
+
if (!name || name.length < 4) return false;
|
|
759
|
+
return String(sessionText ?? '').toLowerCase().includes(String(name).toLowerCase());
|
|
760
|
+
}
|
|
761
|
+
|
|
650
762
|
export function maybeTriggerDream(projectName, config, pluginRoot) {
|
|
651
763
|
dreamTrigTrace('enter', { projectName, hasLlm: Boolean(config?.llm) });
|
|
652
764
|
if (!projectName || projectName === 'unknown') {
|