@pcircle/memesh 4.8.2 → 4.8.5
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 +2 -1
- package/.codex-plugin/mcp.json +7 -0
- package/.codex-plugin/plugin.json +6 -0
- package/AGENTS.md +18 -8
- package/README.de.md +13 -11
- package/README.md +25 -12
- package/README.zh-TW.md +25 -12
- package/dashboard/dist/index.html +3 -3
- package/dist/core/agent-message-inbox.d.ts +3 -2
- package/dist/core/agent-message-inbox.d.ts.map +1 -1
- package/dist/core/agent-message-inbox.js +30 -6
- package/dist/core/agent-message-inbox.js.map +1 -1
- package/dist/core/agent-messaging.d.ts +7 -0
- package/dist/core/agent-messaging.d.ts.map +1 -1
- package/dist/core/agent-messaging.js +51 -24
- package/dist/core/agent-messaging.js.map +1 -1
- package/dist/core/agent-router.d.ts +27 -20
- package/dist/core/agent-router.d.ts.map +1 -1
- package/dist/core/agent-router.js +114 -43
- package/dist/core/agent-router.js.map +1 -1
- package/dist/core/agent-scope-id.d.ts +11 -0
- package/dist/core/agent-scope-id.d.ts.map +1 -0
- package/dist/core/agent-scope-id.js +40 -0
- package/dist/core/agent-scope-id.js.map +1 -0
- package/dist/core/briefing.d.ts +1 -1
- package/dist/core/briefing.d.ts.map +1 -1
- package/dist/core/briefing.js +25 -7
- package/dist/core/briefing.js.map +1 -1
- package/dist/core/citation-rule.d.ts.map +1 -1
- package/dist/core/citation-rule.js +5 -2
- package/dist/core/citation-rule.js.map +1 -1
- package/dist/core/doctor.d.ts +9 -0
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +461 -24
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/dreamer.d.ts.map +1 -1
- package/dist/core/dreamer.js +3 -1
- package/dist/core/dreamer.js.map +1 -1
- package/dist/core/embedder.d.ts.map +1 -1
- package/dist/core/embedder.js +4 -1
- package/dist/core/embedder.js.map +1 -1
- package/dist/core/install-channel.d.ts +3 -0
- package/dist/core/install-channel.d.ts.map +1 -1
- package/dist/core/install-channel.js +79 -2
- package/dist/core/install-channel.js.map +1 -1
- package/dist/core/install-hooks.d.ts.map +1 -1
- package/dist/core/install-hooks.js +10 -6
- package/dist/core/install-hooks.js.map +1 -1
- package/dist/core/lesson-slug.d.ts.map +1 -1
- package/dist/core/lesson-slug.js +6 -4
- package/dist/core/lesson-slug.js.map +1 -1
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +8 -5
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core/llm-client.d.ts.map +1 -1
- package/dist/core/llm-client.js +4 -1
- package/dist/core/llm-client.js.map +1 -1
- package/dist/core/llm-validator.d.ts.map +1 -1
- package/dist/core/llm-validator.js +6 -15
- package/dist/core/llm-validator.js.map +1 -1
- package/dist/core/memory-tool.d.ts.map +1 -1
- package/dist/core/memory-tool.js +3 -1
- package/dist/core/memory-tool.js.map +1 -1
- package/dist/core/ollama-host.d.ts +6 -0
- package/dist/core/ollama-host.d.ts.map +1 -0
- package/dist/core/ollama-host.js +62 -0
- package/dist/core/ollama-host.js.map +1 -0
- package/dist/core/operations.d.ts +1 -1
- package/dist/core/operations.d.ts.map +1 -1
- package/dist/core/operations.js +1 -1
- package/dist/core/operations.js.map +1 -1
- package/dist/core/project-tags.d.ts +2 -0
- package/dist/core/project-tags.d.ts.map +1 -1
- package/dist/core/project-tags.js +29 -1
- package/dist/core/project-tags.js.map +1 -1
- package/dist/core/schema-export.d.ts.map +1 -1
- package/dist/core/schema-export.js +10 -8
- package/dist/core/schema-export.js.map +1 -1
- package/dist/core/version-check.d.ts +1 -0
- package/dist/core/version-check.d.ts.map +1 -1
- package/dist/core/version-check.js +35 -0
- package/dist/core/version-check.js.map +1 -1
- package/dist/core/work-topology.d.ts +3 -0
- package/dist/core/work-topology.d.ts.map +1 -1
- package/dist/core/work-topology.js +24 -2
- package/dist/core/work-topology.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +5 -2
- package/dist/db.js.map +1 -1
- package/dist/host-adapters/claude-channel.d.ts +0 -58
- package/dist/host-adapters/claude-channel.d.ts.map +1 -1
- package/dist/host-adapters/claude-channel.js +0 -242
- package/dist/host-adapters/claude-channel.js.map +1 -1
- package/dist/host-adapters/codex-cli-queue.d.ts.map +1 -1
- package/dist/host-adapters/codex-cli-queue.js +15 -12
- package/dist/host-adapters/codex-cli-queue.js.map +1 -1
- package/dist/host-runtime/acp.d.ts +2 -0
- package/dist/host-runtime/acp.d.ts.map +1 -1
- package/dist/host-runtime/acp.js +6 -1
- package/dist/host-runtime/acp.js.map +1 -1
- package/dist/host-runtime/claude.d.ts +2 -0
- package/dist/host-runtime/claude.d.ts.map +1 -1
- package/dist/host-runtime/claude.js +12 -13
- package/dist/host-runtime/claude.js.map +1 -1
- package/dist/host-runtime/codex-session.d.ts +3 -1
- package/dist/host-runtime/codex-session.d.ts.map +1 -1
- package/dist/host-runtime/codex-session.js +7 -5
- package/dist/host-runtime/codex-session.js.map +1 -1
- package/dist/host-runtime/codex.d.ts +2 -0
- package/dist/host-runtime/codex.d.ts.map +1 -1
- package/dist/host-runtime/codex.js +6 -1
- package/dist/host-runtime/codex.js.map +1 -1
- package/dist/host-runtime/entry.d.ts +5 -0
- package/dist/host-runtime/entry.d.ts.map +1 -0
- package/dist/host-runtime/entry.js +11 -0
- package/dist/host-runtime/entry.js.map +1 -0
- package/dist/host-runtime/router-client.d.ts +2 -0
- package/dist/host-runtime/router-client.d.ts.map +1 -1
- package/dist/host-runtime/router-client.js.map +1 -1
- package/dist/knowledge-graph.d.ts +0 -1
- package/dist/knowledge-graph.d.ts.map +1 -1
- package/dist/knowledge-graph.js +27 -19
- package/dist/knowledge-graph.js.map +1 -1
- package/dist/skills-manifest.json +40 -25
- package/dist/storage/entity-index.d.ts +4 -0
- package/dist/storage/entity-index.d.ts.map +1 -0
- package/dist/storage/entity-index.js +15 -0
- package/dist/storage/entity-index.js.map +1 -0
- package/dist/storage/fts-index.d.ts.map +1 -1
- package/dist/storage/fts-index.js +5 -0
- package/dist/storage/fts-index.js.map +1 -1
- package/dist/storage/graph-repairs.d.ts +11 -1
- package/dist/storage/graph-repairs.d.ts.map +1 -1
- package/dist/storage/graph-repairs.js +241 -30
- package/dist/storage/graph-repairs.js.map +1 -1
- package/dist/transports/agent-messaging.d.ts +10 -1
- package/dist/transports/agent-messaging.d.ts.map +1 -1
- package/dist/transports/agent-messaging.js +92 -9
- package/dist/transports/agent-messaging.js.map +1 -1
- package/dist/transports/cli/cli.d.ts +4 -0
- package/dist/transports/cli/cli.d.ts.map +1 -1
- package/dist/transports/cli/cli.js +88 -38
- package/dist/transports/cli/cli.js.map +1 -1
- package/dist/transports/mcp/handlers.d.ts +12 -8
- package/dist/transports/mcp/handlers.d.ts.map +1 -1
- package/dist/transports/mcp/handlers.js +23 -11
- package/dist/transports/mcp/handlers.js.map +1 -1
- package/dist/transports/schemas.d.ts +22 -17
- package/dist/transports/schemas.d.ts.map +1 -1
- package/dist/transports/schemas.js +25 -11
- package/dist/transports/schemas.js.map +1 -1
- package/docs/platforms/README.md +2 -2
- package/docs/platforms/agent-messaging.md +246 -36
- package/hooks/hooks.json +10 -0
- package/llms-install.md +29 -6
- package/package.json +13 -5
- package/scripts/hooks/_generated/agent-message-inbox.js +30 -6
- package/scripts/hooks/_generated/citation-rule.js +5 -2
- package/scripts/hooks/_generated/fts-index.js +5 -0
- package/scripts/hooks/_generated/work-topology.js +24 -2
- package/scripts/hooks/_shared.js +52 -4
- package/scripts/hooks/decision-nudge.js +152 -0
- package/scripts/hooks/post-commit.js +11 -0
- package/scripts/hooks/pre-compact.js +12 -4
- package/scripts/hooks/session-start.js +61 -26
- package/scripts/upgrade-plugin.sh +600 -81
- package/skills/memesh/SKILL.md +24 -5
- /package/{.mcp.json → .claude-plugin/mcp.json} +0 -0
|
@@ -82,10 +82,15 @@ export function groupTopology(entities, projectName) {
|
|
|
82
82
|
const lessons = [];
|
|
83
83
|
const knowledge = [];
|
|
84
84
|
const evidence = [];
|
|
85
|
+
const global = [];
|
|
85
86
|
const foreign = [];
|
|
86
87
|
for (const e of entities) {
|
|
87
88
|
if (e.type === 'task-state')
|
|
88
89
|
continue;
|
|
90
|
+
if (e.global) {
|
|
91
|
+
global.push(e);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
89
94
|
if (e.foreign) {
|
|
90
95
|
foreign.push(e);
|
|
91
96
|
continue;
|
|
@@ -104,7 +109,7 @@ export function groupTopology(entities, projectName) {
|
|
|
104
109
|
else
|
|
105
110
|
decisions.push(e);
|
|
106
111
|
}
|
|
107
|
-
for (const list of [decisions, lessons, knowledge, evidence, foreign])
|
|
112
|
+
for (const list of [decisions, lessons, knowledge, evidence, global, foreign])
|
|
108
113
|
list.sort(bySignal);
|
|
109
114
|
const sections = [];
|
|
110
115
|
if (decisions.length)
|
|
@@ -115,6 +120,8 @@ export function groupTopology(entities, projectName) {
|
|
|
115
120
|
sections.push({ heading: `What is known about "${projectName}":`, entities: knowledge });
|
|
116
121
|
if (evidence.length)
|
|
117
122
|
sections.push({ heading: `Recent activity in "${projectName}":`, entities: evidence });
|
|
123
|
+
if (global.length)
|
|
124
|
+
sections.push({ heading: 'Global memory — applies across projects:', entities: global });
|
|
118
125
|
if (foreign.length)
|
|
119
126
|
sections.push({ heading: 'From your other projects (may or may not apply here):', entities: foreign });
|
|
120
127
|
return sections;
|
|
@@ -124,6 +131,11 @@ export const DEFAULT_TOPOLOGY_BUDGET = {
|
|
|
124
131
|
maxChars: 4000,
|
|
125
132
|
maxLineChars: 160,
|
|
126
133
|
};
|
|
134
|
+
export const GLOBAL_TOPOLOGY_LIMIT = 3;
|
|
135
|
+
const GLOBAL_TOPOLOGY_BUDGET = {
|
|
136
|
+
maxChars: 640,
|
|
137
|
+
maxLineChars: DEFAULT_TOPOLOGY_BUDGET.maxLineChars,
|
|
138
|
+
};
|
|
127
139
|
export const TOPOLOGY_CANDIDATE_CAP = 400;
|
|
128
140
|
export const SNIPPET_FETCH_CHARS = DEFAULT_TOPOLOGY_BUDGET.maxLineChars * 4;
|
|
129
141
|
export function buildTopologyLines(entities, projectName, budget) {
|
|
@@ -155,12 +167,18 @@ export function buildTopologyLines(entities, projectName, budget) {
|
|
|
155
167
|
export function assembleTopologyBlock(stateLines, pools, projectName, budget = DEFAULT_TOPOLOGY_BUDGET) {
|
|
156
168
|
const seen = new Set();
|
|
157
169
|
const candidates = [];
|
|
170
|
+
const globalCandidates = [];
|
|
158
171
|
for (const pool of pools) {
|
|
159
172
|
for (const e of pool.entities) {
|
|
160
173
|
if (seen.has(e.name))
|
|
161
174
|
continue;
|
|
162
175
|
seen.add(e.name);
|
|
163
|
-
|
|
176
|
+
if (pool.global) {
|
|
177
|
+
globalCandidates.push(e.global ? e : { ...e, global: true });
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
candidates.push(pool.foreign && !e.foreign ? { ...e, foreign: true } : e);
|
|
181
|
+
}
|
|
164
182
|
}
|
|
165
183
|
}
|
|
166
184
|
const lines = [];
|
|
@@ -173,9 +191,13 @@ export function assembleTopologyBlock(stateLines, pools, projectName, budget = D
|
|
|
173
191
|
const topologyLines = remaining > 0
|
|
174
192
|
? buildTopologyLines(candidates, projectName, { ...budget, maxChars: remaining })
|
|
175
193
|
: [];
|
|
194
|
+
const globalLines = buildTopologyLines(globalCandidates, projectName, GLOBAL_TOPOLOGY_BUDGET);
|
|
176
195
|
if (lines.length > 0 && topologyLines.length > 0)
|
|
177
196
|
lines.push('');
|
|
178
197
|
lines.push(...topologyLines);
|
|
198
|
+
if (lines.length > 0 && globalLines.length > 0)
|
|
199
|
+
lines.push('');
|
|
200
|
+
lines.push(...globalLines);
|
|
179
201
|
return lines;
|
|
180
202
|
}
|
|
181
203
|
export function buildReferenceContext(memoryLines) {
|
package/scripts/hooks/_shared.js
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
slugFromRemoteUrl,
|
|
35
35
|
} from './_generated/core-paths.js';
|
|
36
36
|
import { autoCaptureDecision } from './_generated/capture-flag.js';
|
|
37
|
-
export { assembleTopologyBlock, buildReferenceContext, extractCitedMemoryIds, DEFAULT_TOPOLOGY_BUDGET, SNIPPET_FETCH_CHARS, TOPOLOGY_CANDIDATE_CAP } from './_generated/work-topology.js';
|
|
37
|
+
export { assembleTopologyBlock, buildReferenceContext, extractCitedMemoryIds, DEFAULT_TOPOLOGY_BUDGET, GLOBAL_TOPOLOGY_LIMIT, SNIPPET_FETCH_CHARS, TOPOLOGY_CANDIDATE_CAP } from './_generated/work-topology.js';
|
|
38
38
|
export { readRepoState, repoStateLines } from './_generated/repo-state.js';
|
|
39
39
|
export { matchingGuards, guardFromMetadata } from './_generated/guards.js';
|
|
40
40
|
export { writeCitationRule, citationRulePath, CITATION_RULE_BODY } from './_generated/citation-rule.js';
|
|
@@ -585,6 +585,11 @@ export function captureEntity(db, { name, type, observations = [], tags = [], ti
|
|
|
585
585
|
// retried, because the callers dedupe on the entity NAME existing —
|
|
586
586
|
// `INSERT OR IGNORE` reports "already there" on the next run and the
|
|
587
587
|
// half-written state is permanent.
|
|
588
|
+
//
|
|
589
|
+
// Returns `{ id, isNew, observationsWritten }`, or null when the entity row
|
|
590
|
+
// could not be resolved. `observationsWritten` may be lower than
|
|
591
|
+
// `observations.length`: an observation whose exact content is already on
|
|
592
|
+
// the entity is not stored again (see the dedupe in captureEntityInner).
|
|
588
593
|
return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata }))();
|
|
589
594
|
}
|
|
590
595
|
|
|
@@ -655,8 +660,47 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
|
|
|
655
660
|
// BY + the one join rule), via the generated fts-index copy.
|
|
656
661
|
const prevObsText = isNew ? undefined : indexedObservationText(db, id);
|
|
657
662
|
|
|
663
|
+
// Never store the same sentence twice on one entity (#240, widened).
|
|
664
|
+
//
|
|
665
|
+
// #240 was fixed in session-summary.js alone, with an EXISTENCE guard: "if
|
|
666
|
+
// this session already has an entity, bail". Its two siblings never got one
|
|
667
|
+
// and wrote 2,202 duplicate rows on the maintainer's graph —
|
|
668
|
+
// `pre-compact-<sessionId>` 2,188 of them (worst entity: 220 observations,
|
|
669
|
+
// 2 distinct), `commit-<sha>` 14.
|
|
670
|
+
//
|
|
671
|
+
// The guard belongs HERE, on CONTENT, not there, on existence, because a
|
|
672
|
+
// pre-compact entity is per-session and a session legitimately compacts
|
|
673
|
+
// more than once: entity 1947's 110 captures span four days. An existence
|
|
674
|
+
// guard would have dropped a second, genuinely different compaction
|
|
675
|
+
// ("Tool calls: 47", "Files edited: …") along with the identical ones. A
|
|
676
|
+
// content guard drops only rows that add nothing — a re-write of a sentence
|
|
677
|
+
// already stored carries no information, whatever produced it.
|
|
678
|
+
//
|
|
679
|
+
// Unconditional rather than opt-in: every caller of this function is an
|
|
680
|
+
// auto-capture hook writing machine-derived facts, and none of them has a
|
|
681
|
+
// case where re-storing an identical string means something. A flag two of
|
|
682
|
+
// three callers pass would be one more proxy for the question.
|
|
683
|
+
//
|
|
684
|
+
// Filtered ONCE, up front, because `allObsText` below composes the FTS text
|
|
685
|
+
// from this list rather than re-reading the rows. Filtering only at the
|
|
686
|
+
// insert loop would index text for rows that do not exist, and
|
|
687
|
+
// `entities_fts` is contentless: the next delete would not match, which is
|
|
688
|
+
// the "database disk image is malformed" failure this file warns about
|
|
689
|
+
// above. The `seen` set also collapses repeats WITHIN one call.
|
|
690
|
+
const seen = new Set(
|
|
691
|
+
isNew
|
|
692
|
+
? []
|
|
693
|
+
: db.prepare('SELECT content FROM observations WHERE entity_id = ?').all(id).map((r) => r.content),
|
|
694
|
+
);
|
|
695
|
+
const freshObservations = [];
|
|
696
|
+
for (const obs of observations) {
|
|
697
|
+
if (seen.has(obs)) continue;
|
|
698
|
+
seen.add(obs);
|
|
699
|
+
freshObservations.push(obs);
|
|
700
|
+
}
|
|
701
|
+
|
|
658
702
|
const insertObs = db.prepare('INSERT INTO observations (entity_id, content) VALUES (?, ?)');
|
|
659
|
-
for (const obs of
|
|
703
|
+
for (const obs of freshObservations) insertObs.run(id, obs);
|
|
660
704
|
const insertTag = db.prepare('INSERT OR IGNORE INTO tags (entity_id, tag) VALUES (?, ?)');
|
|
661
705
|
for (const tag of tags) insertTag.run(id, tag);
|
|
662
706
|
|
|
@@ -673,7 +717,7 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
|
|
|
673
717
|
// upserted entity's accumulated observation count.
|
|
674
718
|
const obsParts = [];
|
|
675
719
|
if (prevObsText) obsParts.push(prevObsText);
|
|
676
|
-
if (
|
|
720
|
+
if (freshObservations.length) obsParts.push(joinIndexedObservations(freshObservations));
|
|
677
721
|
const allObsText = joinIndexedObservations(obsParts);
|
|
678
722
|
// Current title is fully determined by the branches above — no re-read.
|
|
679
723
|
const currentTitle = isNew
|
|
@@ -681,7 +725,11 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
|
|
|
681
725
|
: ((title !== undefined && title !== previousTitle) ? title : previousTitle);
|
|
682
726
|
insertFtsRow(db, id, name, allObsText, currentTitle);
|
|
683
727
|
|
|
684
|
-
|
|
728
|
+
// `observationsWritten` is what actually landed, which is no longer the same
|
|
729
|
+
// as `observations.length` once the dedupe above can drop rows. A caller
|
|
730
|
+
// that reports a count to the user must read this one — pre-compact.js
|
|
731
|
+
// printed "Saved 2 observations" on a run that stored none.
|
|
732
|
+
return { id, isNew, observationsWritten: freshObservations.length };
|
|
685
733
|
}
|
|
686
734
|
|
|
687
735
|
const PRIVATE_DIR_MODE = 0o700;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Decision Nudge — PostToolUse hook for ExitPlanMode and AskUserQuestion (#277).
|
|
4
|
+
//
|
|
5
|
+
// MeMesh's read side is automatic (SessionStart, PreToolUse Edit/Write inject
|
|
6
|
+
// memories), but the write side had no in-flow trigger: an agent could make
|
|
7
|
+
// several decisions in a session and store none of them until the user said
|
|
8
|
+
// "remember this". `ExitPlanMode` (a plan just got approved) and
|
|
9
|
+
// `AskUserQuestion` (the user just chose between options) are the two tool
|
|
10
|
+
// calls where a decision is most likely to have just been made, so this hook
|
|
11
|
+
// fires there and reminds the model — once per tool per session — to use
|
|
12
|
+
// `remember` if the decision is worth keeping.
|
|
13
|
+
//
|
|
14
|
+
// Contract, in order of importance:
|
|
15
|
+
// 1. NEVER block or slow the tool call: every failure path is a silent
|
|
16
|
+
// pass (with a stderr trace for anything that looks like schema
|
|
17
|
+
// drift), and this hook only ever emits additionalContext — exit code
|
|
18
|
+
// stays 0 no matter what.
|
|
19
|
+
// 2. NEVER open the database. Unlike the capture hooks (post-commit,
|
|
20
|
+
// session-summary, pre-compact), this hook writes nothing to MeMesh
|
|
21
|
+
// itself — it only reminds the model to. Nothing here needs a
|
|
22
|
+
// knowledge-graph handle, so nothing opens one; that keeps the hook
|
|
23
|
+
// fast (well under its 5s budget) and removes an entire class of
|
|
24
|
+
// failure (lock contention, migration, corrupt schema) from a hook
|
|
25
|
+
// whose only job is a one-line reminder.
|
|
26
|
+
// 3. Rate-limited to at most once per tool per session, or a plan-heavy
|
|
27
|
+
// session would see the same line after every ExitPlanMode call. A
|
|
28
|
+
// per-(session, tool) flag file under MEMESH_DIR is the mechanism —
|
|
29
|
+
// see claimNudge() for why, and why it is a NEW mechanism rather than
|
|
30
|
+
// a revived one.
|
|
31
|
+
|
|
32
|
+
import { openSync, closeSync, writeSync, constants as fsConstants } from 'fs';
|
|
33
|
+
import { join } from 'path';
|
|
34
|
+
import { ensurePrivateDir, getMemeshDirFromDbPath } from './_shared.js';
|
|
35
|
+
|
|
36
|
+
// The only two tools this hook is wired to in hooks/hooks.json — kept as an
|
|
37
|
+
// explicit allowlist (not "any PostToolUse call") so a future matcher typo
|
|
38
|
+
// or a Claude Code tool rename fails closed (no nudge) rather than nudging
|
|
39
|
+
// on every tool call.
|
|
40
|
+
const TARGET_TOOLS = new Set(['ExitPlanMode', 'AskUserQuestion']);
|
|
41
|
+
|
|
42
|
+
// Real Claude Code session ids are UUIDs; test fixtures use short slugs like
|
|
43
|
+
// `contract-8`. Restricting to this set is what makes the id safe to embed
|
|
44
|
+
// directly in a filename below — anything that fails this (including a
|
|
45
|
+
// missing/non-string id) exits quietly rather than touching the filesystem.
|
|
46
|
+
const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
47
|
+
|
|
48
|
+
// Bounded stdin read. The real payload here (tool_name, tool_input,
|
|
49
|
+
// session_id, cwd) is a few KB at most — ExitPlanMode's plan text is the
|
|
50
|
+
// largest field — so 1 MiB is generous headroom, not a working limit. This
|
|
51
|
+
// stops a malformed/runaway payload from growing an unbounded string before
|
|
52
|
+
// JSON.parse ever runs.
|
|
53
|
+
const MAX_STDIN_BYTES = 1_048_576;
|
|
54
|
+
|
|
55
|
+
let input = '';
|
|
56
|
+
let overflowed = false;
|
|
57
|
+
process.stdin.setEncoding('utf8');
|
|
58
|
+
process.stdin.on('data', (chunk) => {
|
|
59
|
+
if (overflowed) return;
|
|
60
|
+
input += chunk;
|
|
61
|
+
if (input.length > MAX_STDIN_BYTES) overflowed = true;
|
|
62
|
+
});
|
|
63
|
+
process.stdin.on('end', () => {
|
|
64
|
+
try {
|
|
65
|
+
if (overflowed) return pass();
|
|
66
|
+
|
|
67
|
+
const data = JSON.parse(input);
|
|
68
|
+
|
|
69
|
+
// Schema-flip signal, same convention as post-commit.js / pre-edit-recall.js:
|
|
70
|
+
// tool_name absent means Claude Code changed the payload shape, not that
|
|
71
|
+
// this call is irrelevant. Trace so a rename surfaces day-1 instead of
|
|
72
|
+
// this hook going silently inert.
|
|
73
|
+
if (data?.tool_name === undefined) {
|
|
74
|
+
try { process.stderr.write(`[memesh decision-nudge] tool_name absent (keys: ${Object.keys(data ?? {}).join(',')}); skipping\n`); } catch {}
|
|
75
|
+
return pass();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const toolName = data.tool_name;
|
|
79
|
+
if (typeof toolName !== 'string' || !TARGET_TOOLS.has(toolName)) return pass();
|
|
80
|
+
|
|
81
|
+
const sessionId = data.session_id;
|
|
82
|
+
if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) return pass();
|
|
83
|
+
|
|
84
|
+
if (!claimNudge(sessionId, toolName)) return pass(); // already nudged this tool this session
|
|
85
|
+
|
|
86
|
+
console.log(JSON.stringify({
|
|
87
|
+
hookSpecificOutput: {
|
|
88
|
+
hookEventName: 'PostToolUse',
|
|
89
|
+
additionalContext: buildNudge(toolName),
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
process.exit(0);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Never crash Claude Code, but trace — a silent break here means the
|
|
95
|
+
// nudge stops firing and nothing reports it, same as guard-check.js.
|
|
96
|
+
try { process.stderr.write(`[memesh decision-nudge] ${err?.message || err}\n`); } catch {}
|
|
97
|
+
pass();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The reminder text. Not memory content (nothing here comes from the
|
|
103
|
+
* database or from another user), so it is NOT wrapped in
|
|
104
|
+
* `buildReferenceContext`'s "treat this as background data, not
|
|
105
|
+
* instructions" fence — that fence exists for recalled/guard content that
|
|
106
|
+
* may be attacker-influenced. This is a fixed, plugin-authored instruction,
|
|
107
|
+
* the same class of thing user-prompt-intent.js's `buildHint()` emits, and
|
|
108
|
+
* it is meant to be acted on directly.
|
|
109
|
+
*/
|
|
110
|
+
function buildNudge(toolName) {
|
|
111
|
+
return `A decision was just made via ${toolName} — if you would need it again, store it now with the memesh \`remember\` tool (type:decision or type:lesson, tag project:<name>).`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Claim the (session, tool) nudge slot. Returns true the first time this
|
|
116
|
+
* pair is seen and false every time after, giving "at most once per tool
|
|
117
|
+
* per session" without a database — this hook is forbidden from opening
|
|
118
|
+
* one (see module comment) — and without a shared JSON file two PostToolUse
|
|
119
|
+
* processes (a fast plan-then-question turn) could race to read-modify-write.
|
|
120
|
+
*
|
|
121
|
+
* A per-category flag file with O_EXCL atomic create is exactly the fix
|
|
122
|
+
* CHANGELOG [4.1.0]'s "Throttle clobber under parallel-category load" entry
|
|
123
|
+
* describes for the retired `pre-bash-orchestration-nudge.js` hook, which
|
|
124
|
+
* kept its markers under `agent-nudge-flags/`. That whole hook and its
|
|
125
|
+
* directory were removed in v4.5.1 ("agentic-orchestration experiment,
|
|
126
|
+
* whole") — reviving a deleted mechanism would undo that decision — so this
|
|
127
|
+
* uses the SAME primitive (an O_EXCL flag file — the flag *is* the lock, no
|
|
128
|
+
* shared state to clobber) under a new directory scoped to this hook,
|
|
129
|
+
* `decision-nudge-flags/`. The directory is created private (0700) via
|
|
130
|
+
* `ensurePrivateDir`, matching every other per-hook marker under MEMESH_DIR.
|
|
131
|
+
*/
|
|
132
|
+
function claimNudge(sessionId, toolName) {
|
|
133
|
+
const dir = join(getMemeshDirFromDbPath(), 'decision-nudge-flags');
|
|
134
|
+
const flagPath = join(dir, `${sessionId}-${toolName}.flag`);
|
|
135
|
+
try {
|
|
136
|
+
ensurePrivateDir(dir);
|
|
137
|
+
const fd = openSync(flagPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
|
|
138
|
+
try { writeSync(fd, String(Date.now())); } finally { closeSync(fd); }
|
|
139
|
+
return true;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err?.code === 'EEXIST') return false; // already nudged this session/tool
|
|
142
|
+
// Any other failure (disk full, permission denied) must not block the
|
|
143
|
+
// tool call, and the safe default for a hint is silence rather than a
|
|
144
|
+
// duplicate-nudge storm — so this counts as "already claimed".
|
|
145
|
+
try { process.stderr.write(`[memesh decision-nudge] could not claim ${flagPath}: ${err?.message || err}\n`); } catch {}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function pass() {
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
@@ -168,6 +168,17 @@ process.stdin.on('end', () => {
|
|
|
168
168
|
// TYPE, which a hand-typed `memesh learn` satisfied all by itself.
|
|
169
169
|
// The commit subject IS the title — git authors already wrote a
|
|
170
170
|
// one-line human summary; nothing to synthesize.
|
|
171
|
+
//
|
|
172
|
+
// Re-capture of an already-stored commit is expected and harmless: this
|
|
173
|
+
// hook runs on PostToolUse, so any later Bash call while HEAD is
|
|
174
|
+
// unchanged rebuilds the same `commit-<sha>` payload. It used to APPEND
|
|
175
|
+
// it — three commit entities on the maintainer's graph reached 6
|
|
176
|
+
// observations / 3 distinct, one triple re-written 107 seconds after the
|
|
177
|
+
// first. captureEntity now refuses to store an observation whose exact
|
|
178
|
+
// content is already on the entity (#240, widened), which is the right
|
|
179
|
+
// guard here rather than "skip if the entity exists": a sha is immutable,
|
|
180
|
+
// so any line that DOES differ on a later run (diff stats that failed the
|
|
181
|
+
// first time and succeeded now) is new information and must still land.
|
|
171
182
|
const written = captureEntity(db, {
|
|
172
183
|
name: entityName,
|
|
173
184
|
type: 'commit',
|
|
@@ -159,12 +159,20 @@ process.stdin.on('end', () => {
|
|
|
159
159
|
// it announced a save that may not have happened, with a number that never
|
|
160
160
|
// matched the one entity and handful of observations actually written.
|
|
161
161
|
//
|
|
162
|
-
// `
|
|
163
|
-
//
|
|
164
|
-
//
|
|
162
|
+
// `written.observationsWritten` is the honest count, NOT `obsLines.length`:
|
|
163
|
+
// a session compacts more than once, and the second compaction's lines are
|
|
164
|
+
// usually word-for-word the first's ("Compaction reason: auto", "Tool
|
|
165
|
+
// calls: 0"), which captureEntity now declines to store twice. Reporting
|
|
166
|
+
// the built count would announce "Saved 2 observations" on a run that
|
|
167
|
+
// stored none — the same success-shaped lie this comment block exists to
|
|
168
|
+
// stop. Zero written is still a successful run: the memory is already
|
|
169
|
+
// there.
|
|
170
|
+
const stored = written ? written.observationsWritten : 0;
|
|
165
171
|
const hookOutput = {
|
|
166
172
|
systemMessage: written
|
|
167
|
-
?
|
|
173
|
+
? (stored > 0
|
|
174
|
+
? `Saved ${stored} observations to MeMesh before compaction`
|
|
175
|
+
: 'MeMesh: this compaction added nothing new (already captured)')
|
|
168
176
|
: 'MeMesh: could not save pre-compaction insights (see stderr)',
|
|
169
177
|
};
|
|
170
178
|
console.log(JSON.stringify(hookOutput));
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
importFromPluginRoot,
|
|
16
16
|
assembleTopologyBlock,
|
|
17
17
|
DEFAULT_TOPOLOGY_BUDGET,
|
|
18
|
+
GLOBAL_TOPOLOGY_LIMIT,
|
|
18
19
|
SNIPPET_FETCH_CHARS,
|
|
19
20
|
TOPOLOGY_CANDIDATE_CAP,
|
|
20
21
|
isTrustedForAutoContext,
|
|
@@ -333,11 +334,24 @@ function detectInstallChannelHook(pluginRoot) {
|
|
|
333
334
|
* Claude Code wording rather than suppressing the hint entirely — the
|
|
334
335
|
* banner is more useful naming the majority host than naming none.
|
|
335
336
|
*/
|
|
337
|
+
/**
|
|
338
|
+
* Which plugin host is this install under — or `'unknown'` when we could not
|
|
339
|
+
* ask.
|
|
340
|
+
*
|
|
341
|
+
* `null` is a real answer from `detectPluginHost`: "this path is not under any
|
|
342
|
+
* plugin cache". So `detectPluginHost?.(root) ?? null` inside a bare catch,
|
|
343
|
+
* which is what this was, gave a module that failed to load and a detector
|
|
344
|
+
* that threw the SAME value as a confident "not Codex" — and
|
|
345
|
+
* `pluginUpgradeLine` then handed every Codex user the Claude Code command.
|
|
346
|
+
* `getCurrentInstallChannel` above already returns `'unknown'` for exactly
|
|
347
|
+
* this state; this now matches it.
|
|
348
|
+
*/
|
|
336
349
|
function pluginHostOf(pluginRoot) {
|
|
350
|
+
if (typeof _installChannelMod?.detectPluginHost !== 'function') return 'unknown';
|
|
337
351
|
try {
|
|
338
|
-
return _installChannelMod
|
|
352
|
+
return _installChannelMod.detectPluginHost(pluginRoot);
|
|
339
353
|
} catch {
|
|
340
|
-
return
|
|
354
|
+
return 'unknown';
|
|
341
355
|
}
|
|
342
356
|
}
|
|
343
357
|
|
|
@@ -353,9 +367,17 @@ function pluginHostOf(pluginRoot) {
|
|
|
353
367
|
* happened. One owner, so it cannot happen again.
|
|
354
368
|
*/
|
|
355
369
|
function pluginUpgradeLine(pluginRoot) {
|
|
356
|
-
|
|
370
|
+
const host = pluginHostOf(pluginRoot);
|
|
371
|
+
if (host === 'codex') {
|
|
357
372
|
return ` Run: codex plugin marketplace upgrade pcircle-memesh && codex plugin add memesh@pcircle-memesh`;
|
|
358
373
|
}
|
|
374
|
+
if (host === 'unknown') {
|
|
375
|
+
// Naming one host's command here would be a guess presented as an
|
|
376
|
+
// instruction. Rare — it needs `dist/core/install-channel.js` to be
|
|
377
|
+
// missing or unloadable — but that is a broken install, which is exactly
|
|
378
|
+
// when wrong upgrade advice costs the most.
|
|
379
|
+
return ` Run: memesh upgrade-plugin (on Codex: codex plugin marketplace upgrade pcircle-memesh && codex plugin add memesh@pcircle-memesh)`;
|
|
380
|
+
}
|
|
359
381
|
return ` Run: memesh upgrade-plugin (no CLI? npx @pcircle/memesh upgrade-plugin — or reinstall from /plugin UI)`;
|
|
360
382
|
}
|
|
361
383
|
|
|
@@ -786,9 +808,12 @@ process.stdin.on('end', async () => {
|
|
|
786
808
|
};
|
|
787
809
|
|
|
788
810
|
const projectTag = `project:${projectName}`;
|
|
811
|
+
const notGlobal = colNames.has('namespace')
|
|
812
|
+
? "AND (e.namespace IS NULL OR e.namespace <> 'global')"
|
|
813
|
+
: '';
|
|
789
814
|
const projectQuery = buildScoringQuery(
|
|
790
815
|
`JOIN tags t ON t.entity_id = e.id`,
|
|
791
|
-
`WHERE t.tag = ? ${statusFilter}`,
|
|
816
|
+
`WHERE t.tag = ? ${notGlobal} ${statusFilter}`,
|
|
792
817
|
);
|
|
793
818
|
// Over-fetch WIDE, then filter. The window used to be `sessionLimit * 3`
|
|
794
819
|
// and the trust filter ran after it — so a class of entity that ranks
|
|
@@ -811,26 +836,27 @@ process.stdin.on('end', async () => {
|
|
|
811
836
|
// by nobody (#242): 1814 entities, 2 global, one of them structurally
|
|
812
837
|
// uninjectable. A standing behaviour rule sat in that state for months.
|
|
813
838
|
//
|
|
814
|
-
// Global rows ride in a SEPARATE, small window
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
// branch is simply empty, which is the old behaviour.
|
|
819
|
-
const GLOBAL_SLOTS = 3;
|
|
839
|
+
// Global rows ride in a SEPARATE, small window and the shared assembler
|
|
840
|
+
// gives them a separate render budget. The project keeps `sessionLimit`
|
|
841
|
+
// slots and its full character budget. The column is absent on
|
|
842
|
+
// pre-namespace schemas; then this branch is simply empty.
|
|
820
843
|
let globalEntities = [];
|
|
821
844
|
if (colNames.has('namespace')) {
|
|
822
845
|
const globalQuery = buildScoringQuery('', `WHERE e.namespace = 'global' ${statusFilter}`);
|
|
823
|
-
const projectIds = new Set(projectOnly.map(e => e.id));
|
|
824
846
|
globalEntities = db.prepare(globalQuery).all(CANDIDATE_CAP)
|
|
825
|
-
.filter(entity => isTrustedForAutoContext(entity.metadata)
|
|
826
|
-
.slice(0,
|
|
847
|
+
.filter(entity => isTrustedForAutoContext(entity.metadata))
|
|
848
|
+
.slice(0, GLOBAL_TOPOLOGY_LIMIT);
|
|
827
849
|
}
|
|
828
|
-
const projectEntities =
|
|
850
|
+
const projectEntities = projectOnly.slice(0, sessionLimit);
|
|
829
851
|
|
|
830
852
|
// recentStatusFilter is "WHERE status = 'active'" or "" — the bare-column
|
|
831
853
|
// form is fine when there's no JOIN, but we now alias the table as `e`,
|
|
832
854
|
// so rewrite to e.status for consistency.
|
|
833
|
-
const
|
|
855
|
+
const recentConditions = [
|
|
856
|
+
hasStatus ? "e.status = 'active'" : '',
|
|
857
|
+
colNames.has('namespace') ? "(e.namespace IS NULL OR e.namespace <> 'global')" : '',
|
|
858
|
+
].filter(Boolean);
|
|
859
|
+
const recentWhere = recentConditions.length > 0 ? `WHERE ${recentConditions.join(' AND ')}` : '';
|
|
834
860
|
const recentQuery = buildScoringQuery('', recentWhere);
|
|
835
861
|
const recentEntities = db.prepare(recentQuery).all(CANDIDATE_CAP)
|
|
836
862
|
.filter(entity => isTrustedForAutoContext(entity.metadata))
|
|
@@ -850,6 +876,7 @@ process.stdin.on('end', async () => {
|
|
|
850
876
|
JOIN tags t ON t.entity_id = e.id
|
|
851
877
|
WHERE e.type = 'lesson_learned'
|
|
852
878
|
${hasStatus ? "AND e.status = 'active'" : ''}
|
|
879
|
+
${colNames.has('namespace') ? "AND (e.namespace IS NULL OR e.namespace <> 'global')" : ''}
|
|
853
880
|
AND t.tag = ?
|
|
854
881
|
LIMIT 50
|
|
855
882
|
`).all(projectTag).filter(entity => isTrustedForAutoContext(entity.metadata));
|
|
@@ -866,9 +893,11 @@ process.stdin.on('end', async () => {
|
|
|
866
893
|
// SessionStart system message takes one row in the Claude Code
|
|
867
894
|
// log instead of four. Counts are count-only — no entity bullets.
|
|
868
895
|
const projectCount = projectEntities.length;
|
|
896
|
+
const globalCount = globalEntities.length;
|
|
869
897
|
const recentCount = recentEntities.length;
|
|
870
898
|
const memoryFragments = [];
|
|
871
899
|
if (projectCount > 0) memoryFragments.push(`${projectCount} project`);
|
|
900
|
+
if (globalCount > 0) memoryFragments.push(`${globalCount} global`);
|
|
872
901
|
if (recentCount > 0) memoryFragments.push(`${recentCount} recent`);
|
|
873
902
|
|
|
874
903
|
let summary;
|
|
@@ -915,6 +944,7 @@ process.stdin.on('end', async () => {
|
|
|
915
944
|
const rankedIds = [
|
|
916
945
|
...topLessons.map(e => e.id),
|
|
917
946
|
...projectEntities.map(e => e.id),
|
|
947
|
+
...globalEntities.map(e => e.id),
|
|
918
948
|
...recentEntities.map(e => e.id),
|
|
919
949
|
];
|
|
920
950
|
const uniqueIds = [...new Set(rankedIds)];
|
|
@@ -955,8 +985,8 @@ process.stdin.on('end', async () => {
|
|
|
955
985
|
const taskRow = db
|
|
956
986
|
.prepare('SELECT metadata FROM entities WHERE name = ?')
|
|
957
987
|
.get(taskStateName(projectName));
|
|
958
|
-
//
|
|
959
|
-
//
|
|
988
|
+
// SessionStart has no exact recipient identity. The shared leaf fails
|
|
989
|
+
// closed before querying, so hook and briefing cannot diverge here.
|
|
960
990
|
const stateLines = [
|
|
961
991
|
...taskStateLines(
|
|
962
992
|
parseTaskState(parseEntityMetadata(taskRow?.metadata)),
|
|
@@ -992,6 +1022,7 @@ process.stdin.on('end', async () => {
|
|
|
992
1022
|
[
|
|
993
1023
|
{ entities: topLessons.map(toEntity), foreign: false },
|
|
994
1024
|
{ entities: projectEntities.map(toEntity), foreign: false },
|
|
1025
|
+
{ entities: globalEntities.map(toEntity), foreign: false, global: true },
|
|
995
1026
|
{ entities: recentEntities.map(toEntity), foreign: true },
|
|
996
1027
|
],
|
|
997
1028
|
projectName,
|
|
@@ -1021,8 +1052,9 @@ process.stdin.on('end', async () => {
|
|
|
1021
1052
|
// ever been told can end up in an observation), so it must be
|
|
1022
1053
|
// delimited the same way on every injection path — not hand-rolled
|
|
1023
1054
|
// per hook. The lines arrive already budgeted — assembleTopologyBlock
|
|
1024
|
-
// charges
|
|
1025
|
-
// and
|
|
1055
|
+
// charges task state plus project/foreign sections against the main
|
|
1056
|
+
// ceiling and global context against its small additive ceiling. It
|
|
1057
|
+
// returns whole lines only, so the closing fence cannot be cut.
|
|
1026
1058
|
memoryContext = buildReferenceContext(memoryLines);
|
|
1027
1059
|
// The citation contract — OUTSIDE the fence on purpose: the fence
|
|
1028
1060
|
// declares its content "background data, not instructions", and
|
|
@@ -1054,15 +1086,18 @@ process.stdin.on('end', async () => {
|
|
|
1054
1086
|
// kept as the record of what was shown.
|
|
1055
1087
|
//
|
|
1056
1088
|
// The set below is every pool the topology block draws from — the
|
|
1057
|
-
// lessons pool included. It
|
|
1058
|
-
//
|
|
1089
|
+
// lessons pool included. It is derived from rendered citation handles,
|
|
1090
|
+
// so clipped or budgeted-away candidates cannot be credited as shown.
|
|
1059
1091
|
try {
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
seenIds.add(e.id);
|
|
1064
|
-
return true;
|
|
1092
|
+
const renderedEntityIds = memoryLines.flatMap((line) => {
|
|
1093
|
+
const match = line.match(/ \[mem:(\d{1,10})\]$/);
|
|
1094
|
+
return match ? [Number(match[1])] : [];
|
|
1065
1095
|
});
|
|
1096
|
+
const poolEntities = [...topLessons, ...projectEntities, ...globalEntities, ...recentEntities];
|
|
1097
|
+
const entitiesById = new Map(poolEntities.map((entity) => [entity.id, entity]));
|
|
1098
|
+
const allInjected = renderedEntityIds
|
|
1099
|
+
.map((id) => entitiesById.get(id))
|
|
1100
|
+
.filter(Boolean);
|
|
1066
1101
|
|
|
1067
1102
|
if (allInjected.length > 0) {
|
|
1068
1103
|
const sessionsDir = join(memeshDir, 'sessions');
|