@pcircle/memesh 4.10.1 → 4.10.2
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/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +44 -13
- package/README.de.md +3 -3
- package/README.md +3 -3
- package/README.zh-TW.md +3 -3
- package/dashboard/dist/index.html +1 -1
- package/dist/core/agent-message-inbox.d.ts +6 -0
- package/dist/core/agent-message-inbox.d.ts.map +1 -1
- package/dist/core/agent-message-inbox.js +28 -1
- package/dist/core/agent-message-inbox.js.map +1 -1
- package/dist/core/briefing-level.d.ts +22 -0
- package/dist/core/briefing-level.d.ts.map +1 -0
- package/dist/core/briefing-level.js +75 -0
- package/dist/core/briefing-level.js.map +1 -0
- package/dist/core/briefing.d.ts +3 -0
- package/dist/core/briefing.d.ts.map +1 -1
- package/dist/core/briefing.js +33 -11
- package/dist/core/briefing.js.map +1 -1
- package/dist/core/capture-liveness.d.ts +1 -0
- package/dist/core/capture-liveness.d.ts.map +1 -1
- package/dist/core/capture-liveness.js +1 -0
- package/dist/core/capture-liveness.js.map +1 -1
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +3 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js +4 -0
- package/dist/core/paths.js.map +1 -1
- package/dist/core/schema-export.d.ts.map +1 -1
- package/dist/core/schema-export.js +5 -1
- package/dist/core/schema-export.js.map +1 -1
- package/dist/core/serializer.d.ts +2 -0
- package/dist/core/serializer.d.ts.map +1 -1
- package/dist/core/serializer.js +119 -6
- package/dist/core/serializer.js.map +1 -1
- package/dist/core/task-state.d.ts +5 -0
- package/dist/core/task-state.d.ts.map +1 -1
- package/dist/core/task-state.js +70 -0
- package/dist/core/task-state.js.map +1 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/work-topology.d.ts +1 -0
- package/dist/core/work-topology.d.ts.map +1 -1
- package/dist/core/work-topology.js +3 -0
- package/dist/core/work-topology.js.map +1 -1
- package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
- package/dist/mcp/server.js +448 -121
- package/dist/mcp/server.js.map +3 -3
- package/dist/skills-manifest.json +29 -24
- package/dist/storage/fts-index.d.ts +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/transports/cli/cli.d.ts.map +1 -1
- package/dist/transports/cli/cli.js +348 -53
- package/dist/transports/cli/cli.js.map +3 -3
- package/dist/transports/http/server.d.ts.map +1 -1
- package/dist/transports/http/server.js +10 -1
- package/dist/transports/http/server.js.map +1 -1
- package/dist/transports/mcp/handlers.d.ts +7 -3
- package/dist/transports/mcp/handlers.d.ts.map +1 -1
- package/dist/transports/mcp/handlers.js +7 -3
- package/dist/transports/mcp/handlers.js.map +1 -1
- package/dist/transports/schemas.d.ts +1 -0
- package/dist/transports/schemas.d.ts.map +1 -1
- package/dist/transports/schemas.js +1 -0
- package/dist/transports/schemas.js.map +1 -1
- package/docs/platforms/agent-messaging.md +17 -3
- package/package.json +4 -9
- package/scripts/hooks/_generated/agent-message-inbox.js +28 -1
- package/scripts/hooks/_generated/briefing-level.js +82 -0
- package/scripts/hooks/_generated/capture-liveness.js +1 -0
- package/scripts/hooks/_generated/core-paths.js +4 -0
- package/scripts/hooks/_generated/fts-index.js +5 -0
- package/scripts/hooks/_generated/task-state.js +70 -0
- package/scripts/hooks/_generated/work-topology.js +3 -0
- package/scripts/hooks/_shared.js +623 -15
- package/scripts/hooks/pre-edit-recall.js +386 -65
- package/scripts/hooks/session-start.js +261 -77
- package/scripts/hooks/user-prompt-intent.js +38 -6
- package/scripts/upgrade-plugin.sh +3 -4
- package/skills/memesh/SKILL.md +26 -16
|
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'url';
|
|
|
7
7
|
import { existsSync, readFileSync, unlinkSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
|
|
8
8
|
import {
|
|
9
9
|
buildReferenceContext,
|
|
10
|
+
hasBriefingContent,
|
|
10
11
|
ensurePrivateDir,
|
|
11
12
|
getDbPath,
|
|
12
13
|
getMemeshDirFromDbPath,
|
|
@@ -50,7 +51,15 @@ import {
|
|
|
50
51
|
repoStateLines,
|
|
51
52
|
resolvePluginRoot,
|
|
52
53
|
resolveSessionLimit,
|
|
53
|
-
|
|
54
|
+
briefingTaskStateLines,
|
|
55
|
+
resolveBriefingLevel,
|
|
56
|
+
resolveMessageRecipient,
|
|
57
|
+
waitingMessageLines,
|
|
58
|
+
briefingLevelPolicy,
|
|
59
|
+
sessionStartAppendsWorkPackageNotice,
|
|
60
|
+
WORK_PACKAGE_NOTICE,
|
|
61
|
+
readHookConfigResult,
|
|
62
|
+
HOOK_CONFIG_UNREADABLE_REASON,
|
|
54
63
|
homeDir,
|
|
55
64
|
taskStateName,
|
|
56
65
|
writeCitationRule,
|
|
@@ -58,7 +67,6 @@ import {
|
|
|
58
67
|
writePrivateJson,
|
|
59
68
|
} from './_shared.js';
|
|
60
69
|
import { MemeshDatabase } from './_generated/sqlite.js';
|
|
61
|
-
import { unreadDeliveryCount, unreadInboxLines } from './_generated/agent-message-inbox.js';
|
|
62
70
|
import {
|
|
63
71
|
buildBriefingIndex,
|
|
64
72
|
INDEX_CANDIDATE_CAP,
|
|
@@ -68,13 +76,12 @@ import {
|
|
|
68
76
|
|
|
69
77
|
const require = createRequire(import.meta.url);
|
|
70
78
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// not on every call. Falls back to null if the dist file is
|
|
79
|
+
// dist/core/install-channel.js is emitted as ESM (the project's tsconfig
|
|
80
|
+
// produces NodeNext modules). On Node 20.x `require()` against an ESM file
|
|
81
|
+
// throws ERR_REQUIRE_ESM, which would silently downgrade all install-channel
|
|
82
|
+
// detection to 'unknown' on the supported floor. So the module is pre-loaded
|
|
83
|
+
// via dynamic `import()` at hook startup using a top-level await — once at
|
|
84
|
+
// process init, not on every call. Falls back to null if the dist file is
|
|
78
85
|
// missing (source checkout pre-build) or fails to load.
|
|
79
86
|
let _installChannelMod = null;
|
|
80
87
|
try {
|
|
@@ -124,32 +131,26 @@ function buildDeprecationBanner(currentVersion, cache) {
|
|
|
124
131
|
`⚠️ MeMesh ${currentVersion} is DEPRECATED by maintainers.`,
|
|
125
132
|
` ${msg}`,
|
|
126
133
|
];
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
// cases, and the session-start banner should match — `npm`
|
|
135
|
-
// resolves @latest at install time, so the command works even
|
|
136
|
-
// when our local cache is uncertain.
|
|
134
|
+
// A remediation line is emitted for EVERY deprecation banner — including
|
|
135
|
+
// when the cached `latestVersion` is null, equal to current, or stale — so
|
|
136
|
+
// a security warning always carries a follow-up step. doctor / CLI status /
|
|
137
|
+
// dashboard point at `memesh update` (or channel equivalents) in those
|
|
138
|
+
// uncertain cases too, and the session-start banner matches: `npm`
|
|
139
|
+
// resolves @latest at install time, so the command works even when our
|
|
140
|
+
// local cache is uncertain.
|
|
137
141
|
const knownUpgradeTarget = Boolean(
|
|
138
142
|
cache.latestVersion && cache.latestVersion !== currentVersion,
|
|
139
143
|
);
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
// `memesh
|
|
150
|
-
// no target, and immediately applies a freshly-published fix
|
|
151
|
-
// when there is one). The "no target yet" message remains
|
|
152
|
-
// available in `memesh status` (fresh lookup) and the dashboard
|
|
144
|
+
// The SessionStart hook reads ONLY cached update data — no fresh lookup
|
|
145
|
+
// happens on this code path. That means `freshness === 'fresh'` (the strict
|
|
146
|
+
// rule the dashboard / `memesh status` use to authoritatively say "no
|
|
147
|
+
// upgrade target yet") can never apply here. A 24h-window heuristic that
|
|
148
|
+
// fired the no-target message anyway would suppress the upgrade hint
|
|
149
|
+
// exactly when a security-advisory fix could ship within the window, so
|
|
150
|
+
// the remediation is conservative: always recommend `memesh update` (a
|
|
151
|
+
// harmless no-op when there's truly no target, and it immediately applies
|
|
152
|
+
// a freshly-published fix when there is one). The "no target yet" message
|
|
153
|
+
// remains available in `memesh status` (fresh lookup) and the dashboard
|
|
153
154
|
// (after a Check now click).
|
|
154
155
|
// Tailor the remediation hint to the install channel. `memesh
|
|
155
156
|
// update` and `autoUpdate` only work for npm-global installs;
|
|
@@ -177,9 +178,8 @@ function buildDeprecationBanner(currentVersion, cache) {
|
|
|
177
178
|
} else if (channel === 'source-checkout') {
|
|
178
179
|
lines.push(` Source checkout: pull and rebuild (\`git pull && npm install && npm run build\`).`);
|
|
179
180
|
} else if (channel === 'npm-local') {
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// banner). Pinning a specific version risks installing an
|
|
181
|
+
// The cached `latestVersion` may itself be stale (cache TTL is 24h
|
|
182
|
+
// and we're already showing a stale banner). Pinning a specific version risks installing an
|
|
183
183
|
// already-superseded build that's part of the same security
|
|
184
184
|
// advisory. `@latest` always resolves to the registry's
|
|
185
185
|
// current dist-tag at install time, which is the right
|
|
@@ -497,14 +497,13 @@ function spawnFreshUpdateCheck(installedVersion) {
|
|
|
497
497
|
// the banner marker above: marker and cache must share a directory.
|
|
498
498
|
const dir = memeshHomeDir();
|
|
499
499
|
try { ensurePrivateDir(dir); } catch { /* best-effort */ }
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
// markers ensure each install gets its own refresh window.
|
|
500
|
+
// The throttle marker is scoped to the installed version. A
|
|
501
|
+
// machine-global marker would let a refresh started by a global 4.1.3
|
|
502
|
+
// install suppress refreshes for a sibling project-local 4.1.1 for the
|
|
503
|
+
// next 5 minutes — and the shared cache it wrote would carry version
|
|
504
|
+
// 4.1.3, so the 4.1.1 session would skip its banner because
|
|
505
|
+
// `cache.currentVersion !== currentVersion`. Per-version markers give
|
|
506
|
+
// each install its own refresh window.
|
|
508
507
|
// Sanitize version for filesystem (semver chars only, no path
|
|
509
508
|
// separators); fall back to 'unknown' if missing.
|
|
510
509
|
const versionTag = typeof installedVersion === 'string'
|
|
@@ -512,13 +511,12 @@ function spawnFreshUpdateCheck(installedVersion) {
|
|
|
512
511
|
? installedVersion
|
|
513
512
|
: 'unknown';
|
|
514
513
|
const markerPath = join(dir, `last-fresh-refresh.${versionTag}.lock`);
|
|
515
|
-
// Single-owner claim: O_EXCL atomic create.
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
// ownership primitive for its separate update lock.
|
|
514
|
+
// Single-owner claim: O_EXCL atomic create. A temp+rename+readback
|
|
515
|
+
// pattern is racy — both peers' renames are destructive, so each could
|
|
516
|
+
// read its own token back and both would spawn a refresh. O_EXCL is the
|
|
517
|
+
// standard POSIX/libuv primitive that lets at most one process succeed.
|
|
518
|
+
// The updater runner uses the same O_EXCL ownership primitive for its
|
|
519
|
+
// separate update lock.
|
|
522
520
|
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
523
521
|
const claim = () => {
|
|
524
522
|
try {
|
|
@@ -799,6 +797,18 @@ function combineWithBanner(baseMessage, { skipUpdateBanner = false } = {}) {
|
|
|
799
797
|
return [...lines.filter((l) => l.length > 0), '', baseMessage].join('\n');
|
|
800
798
|
}
|
|
801
799
|
|
|
800
|
+
// #360: the ONE reason string for "this session's memory injection resolved
|
|
801
|
+
// to nothing" — every exit path that ends up with a falsy `memoryContext`
|
|
802
|
+
// passes this through `output()`'s `recorded` argument, so a genuinely silent
|
|
803
|
+
// session (`minimal` on an empty project, the no-database and
|
|
804
|
+
// no-entities-table early exits) always leaves a trace of WHY nothing was
|
|
805
|
+
// injected, instead of the generic `session-start-banner` outcome marker that
|
|
806
|
+
// carries no `reason`. One helper, not three copies of the template string,
|
|
807
|
+
// so the wording cannot drift between call sites.
|
|
808
|
+
function nothingToInjectReason(level, detail) {
|
|
809
|
+
return `briefing-level: nothing to inject at "${level}" — ${detail}`;
|
|
810
|
+
}
|
|
811
|
+
|
|
802
812
|
let input = '';
|
|
803
813
|
process.stdin.setEncoding('utf8');
|
|
804
814
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
@@ -809,13 +819,70 @@ process.stdin.on('end', async () => {
|
|
|
809
819
|
let captureWarning = null;
|
|
810
820
|
const withCaptureWarning = (msg) => {
|
|
811
821
|
if (!captureWarning) return msg;
|
|
812
|
-
|
|
822
|
+
// The populated-database path prepends an update banner ahead of the
|
|
823
|
+
// "◉ MeMesh ready · ..." summary line (`finalMessage = [...bannerLines,
|
|
824
|
+
// '', summary].join('\n')`), so that line is not at the START of `msg`
|
|
825
|
+
// whenever a banner is present — an un-flagged `^` only anchors to the
|
|
826
|
+
// whole string's start, so the demotion below would silently do nothing
|
|
827
|
+
// and "MeMesh ready" would leak through even while a write failure was
|
|
828
|
+
// being reported in the very same message. `m` anchors `^` to the start
|
|
829
|
+
// of ANY line instead.
|
|
830
|
+
return `${captureWarning}\n${msg.replace(/^◉ MeMesh ready · /m, '◉ MeMesh · ')}`;
|
|
813
831
|
};
|
|
814
832
|
try {
|
|
815
833
|
try {
|
|
816
834
|
const data = JSON.parse(input);
|
|
817
835
|
const projectName = getProjectName(data.cwd);
|
|
818
836
|
|
|
837
|
+
// #360 — resolve the briefing level ONCE, before any exit path, so every
|
|
838
|
+
// emit below (including the no-database and empty-database early
|
|
839
|
+
// returns) agrees on what to include. env > config > default; an
|
|
840
|
+
// unknown value on either source is not a silent fallback — it is
|
|
841
|
+
// traced AND recorded on the hook's outcome channel, the same
|
|
842
|
+
// discipline every other silent-skip path in this file follows.
|
|
843
|
+
const configRead = readHookConfigResult(process.env);
|
|
844
|
+
const briefingResolution = resolveBriefingLevel(process.env, configRead.config);
|
|
845
|
+
const briefingLevel = briefingResolution.level;
|
|
846
|
+
const briefingPolicy = briefingLevelPolicy(briefingLevel);
|
|
847
|
+
if (briefingResolution.invalid) {
|
|
848
|
+
const { source, value } = briefingResolution.invalid;
|
|
849
|
+
try {
|
|
850
|
+
process.stderr.write(
|
|
851
|
+
`[memesh session-start] invalid ${source} briefing level ${value} — using "${briefingLevel}"\n`,
|
|
852
|
+
);
|
|
853
|
+
} catch { /* stderr gone */ }
|
|
854
|
+
recordHookOutcome(process.env, {
|
|
855
|
+
hook: 'session-start',
|
|
856
|
+
outcome: 'notified',
|
|
857
|
+
reason: `briefing-level: invalid ${source} value ${value}, using ${briefingLevel}`,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
// The check above only catches an unusable VALUE for one known key
|
|
861
|
+
// (`briefing`) inside an otherwise-parseable config object. A config.json
|
|
862
|
+
// that is not even that — truncated JSON, a bare array/string/number/null
|
|
863
|
+
// at the top level — is swallowed by `readHookConfig()` into an empty
|
|
864
|
+
// `{}`, so every setting in it (not just `briefing`) reads as "not set"
|
|
865
|
+
// with no trace anywhere, while the CLI/MCP side (`core/config.ts`'s
|
|
866
|
+
// `warnUnreadable()`) reports this exact state. `readHookConfigResult()`'s
|
|
867
|
+
// `state` is what makes it visible here too — recorded once, regardless
|
|
868
|
+
// of which settings would have been affected, since the whole document
|
|
869
|
+
// was unusable, not one field.
|
|
870
|
+
if (configRead.state === 'unreadable') {
|
|
871
|
+
recordHookOutcome(process.env, {
|
|
872
|
+
hook: 'session-start',
|
|
873
|
+
outcome: 'notified',
|
|
874
|
+
reason: HOOK_CONFIG_UNREADABLE_REASON,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
// The work-package notice is identical boilerplate every session (#360)
|
|
878
|
+
// — only `full` still carries it. `undefined`, not a conditional string
|
|
879
|
+
// literal at each call site: output()'s memoryContext parameter treats
|
|
880
|
+
// undefined as "nothing to add" the same way it always has.
|
|
881
|
+
// Read through the named predicate, not `.workPackageNotice` off the
|
|
882
|
+
// policy object directly — this file is the ONLY caller that should ever
|
|
883
|
+
// decide this (see the field comment in briefing-level.ts).
|
|
884
|
+
const workPackageNotice = sessionStartAppendsWorkPackageNotice(briefingLevel) ? WORK_PACKAGE_NOTICE : undefined;
|
|
885
|
+
|
|
819
886
|
// Self-heal the citation contract.
|
|
820
887
|
//
|
|
821
888
|
// `install-hooks` writes it too, but a PLUGIN install never runs that
|
|
@@ -911,8 +978,22 @@ process.stdin.on('end', async () => {
|
|
|
911
978
|
captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work',
|
|
912
979
|
{ skipUpdateBanner: alreadyNoticed },
|
|
913
980
|
);
|
|
914
|
-
|
|
915
|
-
|
|
981
|
+
const noDbContext = consent
|
|
982
|
+
? [consent.context, workPackageNotice].filter(Boolean).join('\n\n')
|
|
983
|
+
: workPackageNotice;
|
|
984
|
+
output(
|
|
985
|
+
consent ? `${consent.system}\n${emptySummary}` : emptySummary,
|
|
986
|
+
noDbContext,
|
|
987
|
+
// Nothing was injected (no notice at this level, no database to read
|
|
988
|
+
// from) — record why, the same as the schema-present empty path
|
|
989
|
+
// further down. `standard`/`full` reach this exact same branch: at
|
|
990
|
+
// `full` `noDbContext` is always truthy (the notice), so this never
|
|
991
|
+
// overrides anything for that level; at `standard` it can also be
|
|
992
|
+
// falsy here, which correctly gets the same reason (the record is
|
|
993
|
+
// level-agnostic, matching the schema-present path's own
|
|
994
|
+
// `!memoryContext` predicate, not a `minimal`-only check).
|
|
995
|
+
!noDbContext ? { outcome: 'notified', reason: nothingToInjectReason(briefingLevel, 'no database yet') } : null,
|
|
996
|
+
);
|
|
916
997
|
if (consent) finalizeUpdatePromptClaim(data.session_id, consentVersion, consentCache?.latestVersion);
|
|
917
998
|
return;
|
|
918
999
|
}
|
|
@@ -939,7 +1020,14 @@ process.stdin.on('end', async () => {
|
|
|
939
1020
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='entities'"
|
|
940
1021
|
).get();
|
|
941
1022
|
if (!tableCheck) {
|
|
942
|
-
output(
|
|
1023
|
+
output(
|
|
1024
|
+
combineWithBanner(captureWarning ?? '◉ MeMesh ready · database initialised but no memories stored yet'),
|
|
1025
|
+
workPackageNotice,
|
|
1026
|
+
// Same reason mechanism as the no-database exit above and the
|
|
1027
|
+
// schema-present exit below — see `nothingToInjectReason`'s own
|
|
1028
|
+
// comment for why this is not `minimal`-gated explicitly.
|
|
1029
|
+
!workPackageNotice ? { outcome: 'notified', reason: nothingToInjectReason(briefingLevel, 'database has no entities table yet') } : null,
|
|
1030
|
+
);
|
|
943
1031
|
return;
|
|
944
1032
|
}
|
|
945
1033
|
|
|
@@ -1074,8 +1162,11 @@ process.stdin.on('end', async () => {
|
|
|
1074
1162
|
// gives them a separate render budget. The project keeps `sessionLimit`
|
|
1075
1163
|
// slots and its full character budget. The column is absent on
|
|
1076
1164
|
// pre-namespace schemas; then this branch is simply empty.
|
|
1165
|
+
// #360: minimal/standard skip these two queries outright rather than
|
|
1166
|
+
// fetch-then-not-render — the whole point of the level is to stop
|
|
1167
|
+
// paying for what is not the current project.
|
|
1077
1168
|
let globalEntities = [];
|
|
1078
|
-
if (colNames.has('namespace')) {
|
|
1169
|
+
if (briefingPolicy.global && colNames.has('namespace')) {
|
|
1079
1170
|
const globalQuery = buildScoringQuery('', `WHERE e.namespace = 'global' ${statusFilter}`);
|
|
1080
1171
|
globalEntities = db.prepare(globalQuery).all(CANDIDATE_CAP)
|
|
1081
1172
|
.filter(entity => isTrustedForAutoContext(entity.metadata))
|
|
@@ -1086,15 +1177,18 @@ process.stdin.on('end', async () => {
|
|
|
1086
1177
|
// recentStatusFilter is "WHERE status = 'active'" or "" — the bare-column
|
|
1087
1178
|
// form is fine when there's no JOIN, but we now alias the table as `e`,
|
|
1088
1179
|
// so rewrite to e.status for consistency.
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
.
|
|
1180
|
+
let recentEntities = [];
|
|
1181
|
+
if (briefingPolicy.foreign) {
|
|
1182
|
+
const recentConditions = [
|
|
1183
|
+
hasStatus ? "e.status = 'active'" : '',
|
|
1184
|
+
colNames.has('namespace') ? "(e.namespace IS NULL OR e.namespace <> 'global')" : '',
|
|
1185
|
+
].filter(Boolean);
|
|
1186
|
+
const recentWhere = recentConditions.length > 0 ? `WHERE ${recentConditions.join(' AND ')}` : '';
|
|
1187
|
+
const recentQuery = buildScoringQuery('', recentWhere);
|
|
1188
|
+
recentEntities = db.prepare(recentQuery).all(CANDIDATE_CAP)
|
|
1189
|
+
.filter(entity => isTrustedForAutoContext(entity.metadata))
|
|
1190
|
+
.slice(0, 5);
|
|
1191
|
+
}
|
|
1098
1192
|
|
|
1099
1193
|
// Lesson count (queried for summary, not listed individually).
|
|
1100
1194
|
// Status-column gate matches the project/recent queries above —
|
|
@@ -1174,6 +1268,7 @@ process.stdin.on('end', async () => {
|
|
|
1174
1268
|
const topLessons = lessonEntities.slice(0, 5);
|
|
1175
1269
|
|
|
1176
1270
|
const memoryLines = [];
|
|
1271
|
+
let memoryAssemblyFailed = false;
|
|
1177
1272
|
try {
|
|
1178
1273
|
const rankedIds = [
|
|
1179
1274
|
...topLessons.map(e => e.id),
|
|
@@ -1219,14 +1314,31 @@ process.stdin.on('end', async () => {
|
|
|
1219
1314
|
const taskRow = db
|
|
1220
1315
|
.prepare('SELECT metadata FROM entities WHERE name = ?')
|
|
1221
1316
|
.get(taskStateName(projectName));
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1317
|
+
// Who this session is comes from `MEMESH_RECIPIENT` (see
|
|
1318
|
+
// resolveMessageRecipient). Without it there is no exact recipient
|
|
1319
|
+
// and the shared leaf returns no lines, so a session never sees a
|
|
1320
|
+
// message addressed to anyone else.
|
|
1321
|
+
// #360: briefingTaskStateLines downgrades a stale record to one line
|
|
1322
|
+
// at EVERY level, and only consults `briefingPolicy.taskState` for a
|
|
1323
|
+
// fresh one — `minimal` omits a fresh state entirely, never a stale
|
|
1324
|
+
// flag. The unread-inbox line below is unconditional at every level:
|
|
1325
|
+
// a message waiting for this agent is not "another project's
|
|
1326
|
+
// memory", it is addressed to it. An inbox that cannot be read is
|
|
1327
|
+
// recorded as its own `error` (a label, like the two below), and is
|
|
1328
|
+
// not a failed memory assembly: the rest of the context still ships.
|
|
1224
1329
|
const stateLines = [
|
|
1225
|
-
...
|
|
1330
|
+
...briefingTaskStateLines(
|
|
1226
1331
|
parseTaskState(parseEntityMetadata(taskRow?.metadata)),
|
|
1227
1332
|
projectName,
|
|
1333
|
+
new Date(),
|
|
1334
|
+
{ includeFresh: briefingPolicy.taskState },
|
|
1228
1335
|
),
|
|
1229
|
-
...
|
|
1336
|
+
...waitingMessageLines(db, resolveMessageRecipient(process.env), (err) =>
|
|
1337
|
+
recordHookOutcome(process.env, {
|
|
1338
|
+
hook: 'session-start',
|
|
1339
|
+
outcome: 'error',
|
|
1340
|
+
reason: `inbox: ${hookErrorReason(err)}`,
|
|
1341
|
+
})),
|
|
1230
1342
|
];
|
|
1231
1343
|
|
|
1232
1344
|
// The pools overlap by construction (a lesson tagged to this project
|
|
@@ -1268,6 +1380,16 @@ process.stdin.on('end', async () => {
|
|
|
1268
1380
|
// means memories stop reaching the model again (the exact v4.2.7
|
|
1269
1381
|
// regression this block was written to fix).
|
|
1270
1382
|
try { process.stderr.write(`[memesh session-start] memory-context: ${err?.message || err}\n`); } catch {}
|
|
1383
|
+
// Recorded like the index read's failure below (a label, never the
|
|
1384
|
+
// message), so a failed assembly is not later reported as an empty
|
|
1385
|
+
// project: at `minimal` the index read is skipped, and this is the
|
|
1386
|
+
// only place the fault can surface.
|
|
1387
|
+
memoryAssemblyFailed = true;
|
|
1388
|
+
recordHookOutcome(process.env, {
|
|
1389
|
+
hook: 'session-start',
|
|
1390
|
+
outcome: 'error',
|
|
1391
|
+
reason: `memory-context: ${hookErrorReason(err)}`,
|
|
1392
|
+
});
|
|
1271
1393
|
}
|
|
1272
1394
|
|
|
1273
1395
|
// --- The durable-memory index (#323) -----------------------------
|
|
@@ -1284,7 +1406,14 @@ process.stdin.on('end', async () => {
|
|
|
1284
1406
|
// credits a ranked one — the line carries a `[mem:id]` handle, it was
|
|
1285
1407
|
// shown, and a cite of it must not earn nothing.
|
|
1286
1408
|
const indexEntities = [];
|
|
1287
|
-
|
|
1409
|
+
// #360: `minimal` skips the index query outright — unlike the durable
|
|
1410
|
+
// index EXPOSED by the `briefing` tool/CLI (a separately-requestable
|
|
1411
|
+
// fact about the project), nothing else in this hook reads it, so
|
|
1412
|
+
// there is no reason to pay for a read whose only consumer is a
|
|
1413
|
+
// section this level does not render.
|
|
1414
|
+
if (!briefingPolicy.index) {
|
|
1415
|
+
indexLines = [];
|
|
1416
|
+
} else try {
|
|
1288
1417
|
const excluded = INDEX_EXCLUDED_TYPES.map(() => '?').join(',');
|
|
1289
1418
|
const indexRows = db.prepare(
|
|
1290
1419
|
`SELECT e.id, e.name, e.type,${hasTitle ? ' e.title,' : ''} e.metadata,
|
|
@@ -1355,7 +1484,10 @@ process.stdin.on('end', async () => {
|
|
|
1355
1484
|
if (memoryLines.length > 0) {
|
|
1356
1485
|
const repoLines = repoStateLines(readRepoState(data.cwd));
|
|
1357
1486
|
if (repoLines.length > 0) memoryLines.unshift(...repoLines, '');
|
|
1358
|
-
|
|
1487
|
+
// #360: only spacer-then-index when the index actually has lines to
|
|
1488
|
+
// show (`minimal` sets indexLines = [] above) — otherwise this would
|
|
1489
|
+
// leave a dangling blank line with nothing after it.
|
|
1490
|
+
if (indexLines.length > 0) memoryLines.push('');
|
|
1359
1491
|
}
|
|
1360
1492
|
memoryLines.push(...indexLines);
|
|
1361
1493
|
// Same wrapper pre-edit-recall uses: an explicit "background data,
|
|
@@ -1367,7 +1499,33 @@ process.stdin.on('end', async () => {
|
|
|
1367
1499
|
// charges task state plus project/foreign sections against the main
|
|
1368
1500
|
// ceiling and global context against its small additive ceiling. It
|
|
1369
1501
|
// returns whole lines only, so the closing fence cannot be cut.
|
|
1370
|
-
|
|
1502
|
+
// #360: the work-package notice is `full`-only boilerplate; at every
|
|
1503
|
+
// other level workPackageNotice is undefined and this is just the
|
|
1504
|
+
// fenced block.
|
|
1505
|
+
//
|
|
1506
|
+
// When `memoryLines` is genuinely empty (no project content —
|
|
1507
|
+
// `minimal` with nothing durable yet, and no repository-state prefix
|
|
1508
|
+
// because that only prepends onto EXISTING topology lines) and there is
|
|
1509
|
+
// no work-package notice either, wrapping nothing in the preamble + an
|
|
1510
|
+
// empty ```text``` fence would spend 166+ characters informing the
|
|
1511
|
+
// agent of literally nothing. Inject NOTHING instead.
|
|
1512
|
+
// This can only happen at `minimal`: at `standard`/`full`,
|
|
1513
|
+
// `indexLines` always carries at least its own empty-state line
|
|
1514
|
+
// (#323 — "an index is a claim about the user's data", pinned by
|
|
1515
|
+
// tests/core/briefing.test.ts and unchanged here on purpose), so
|
|
1516
|
+
// `memoryLines` there is never actually empty.
|
|
1517
|
+
//
|
|
1518
|
+
// `hasBriefingContent` (work-topology.ts), not `memoryLines.length
|
|
1519
|
+
// === 0` inline: `assembleBriefing` (src/core/briefing.ts) makes the
|
|
1520
|
+
// IDENTICAL decision for the SAME reason on its own `block` array —
|
|
1521
|
+
// the hook cannot call that function directly (A1a: a hook cannot
|
|
1522
|
+
// import `../db.js`-dependent core modules), so this is the one place
|
|
1523
|
+
// the two sides CAN share the rule.
|
|
1524
|
+
const memoryContext = !hasBriefingContent(memoryLines) && !workPackageNotice
|
|
1525
|
+
? undefined
|
|
1526
|
+
: workPackageNotice
|
|
1527
|
+
? buildReferenceContext(memoryLines) + '\n\n' + workPackageNotice
|
|
1528
|
+
: buildReferenceContext(memoryLines);
|
|
1371
1529
|
// The citation contract — OUTSIDE the fence on purpose: the fence
|
|
1372
1530
|
// declares its content "background data, not instructions", and
|
|
1373
1531
|
// this line IS an instruction. One line is the entire write side of
|
|
@@ -1505,9 +1663,26 @@ process.stdin.on('end', async () => {
|
|
|
1505
1663
|
? [...bannerLines.filter(l => l.length > 0), '', summary].join('\n')
|
|
1506
1664
|
: summary;
|
|
1507
1665
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1666
|
+
// memoryContext can now be undefined (nothing to inject at this level)
|
|
1667
|
+
// — guard the concat so an empty injection does not become the LITERAL
|
|
1668
|
+
// string "undefined" glued onto a real consent prompt.
|
|
1669
|
+
output(
|
|
1670
|
+
withCaptureWarning(finalMessage),
|
|
1671
|
+
updateConsentContext
|
|
1672
|
+
? (memoryContext ? `${updateConsentContext}\n\n${memoryContext}` : updateConsentContext)
|
|
1673
|
+
: memoryContext,
|
|
1674
|
+
// A specific, greppable reason for the "genuinely nothing to inject"
|
|
1675
|
+
// case, distinct from the generic 'session-start-banner' entity
|
|
1676
|
+
// marker output() would otherwise record for ANY falsy
|
|
1677
|
+
// memoryContext. The no-database and no-entities-table early exits
|
|
1678
|
+
// above record through the SAME `nothingToInjectReason` helper with
|
|
1679
|
+
// their own `detail`. Not recorded when the memory assembly threw:
|
|
1680
|
+
// that failure has its own error record, and an empty result it
|
|
1681
|
+
// caused must not also be reported as an empty project.
|
|
1682
|
+
!memoryContext && !memoryAssemblyFailed
|
|
1683
|
+
? { outcome: 'notified', reason: nothingToInjectReason(briefingLevel, 'no project content, no repository state, index excluded') }
|
|
1684
|
+
: null,
|
|
1685
|
+
);
|
|
1511
1686
|
if (updateConsentContext) {
|
|
1512
1687
|
finalizeUpdatePromptClaim(data.session_id, installedVersion, updateCache?.latestVersion);
|
|
1513
1688
|
}
|
|
@@ -1621,9 +1796,18 @@ process.stdin.on('end', async () => {
|
|
|
1621
1796
|
*
|
|
1622
1797
|
* The shape is asserted by tests/helpers/hook-output-contract.ts.
|
|
1623
1798
|
*/
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1799
|
+
// The notice's literal text is `_shared.js`'s exported `WORK_PACKAGE_NOTICE`
|
|
1800
|
+
// — single owner, shared with the test suite instead of a second hardcoded
|
|
1801
|
+
// copy there.
|
|
1802
|
+
|
|
1803
|
+
// #360: `memoryContext` deliberately has no default. A default of
|
|
1804
|
+
// `WORK_PACKAGE_NOTICE` would give any call site that passed only `text` the
|
|
1805
|
+
// notice regardless of level — exactly the kind of default that silently
|
|
1806
|
+
// reintroduces what a level was supposed to drop. Every call site passes its
|
|
1807
|
+
// memoryContext (or `workPackageNotice`, already gated) explicitly;
|
|
1808
|
+
// `undefined` here means "this exit has nothing to add", not "fall back to
|
|
1809
|
+
// the notice".
|
|
1810
|
+
function output(text, memoryContext = undefined, recorded = null) {
|
|
1627
1811
|
// session-start's only effect is the context it injects, so it records
|
|
1628
1812
|
// `notified`, not `wrote`: doctor's `writes` answers "is memory capture
|
|
1629
1813
|
// still alive", and injected context is something this hook READ, not
|
|
@@ -32,11 +32,12 @@ import {
|
|
|
32
32
|
readUpdateCheckCache,
|
|
33
33
|
readUpdatePromptClaim,
|
|
34
34
|
resolvePluginRoot,
|
|
35
|
+
unreadMessageLines,
|
|
35
36
|
writeAutoUpdateConsent,
|
|
36
37
|
writeSnooze,
|
|
37
38
|
} from './_shared.js';
|
|
38
39
|
import { join } from 'path';
|
|
39
|
-
import { existsSync, readFileSync } from 'fs';
|
|
40
|
+
import { existsSync, readFileSync, realpathSync } from 'fs';
|
|
40
41
|
|
|
41
42
|
let installChannelMod = null;
|
|
42
43
|
try {
|
|
@@ -217,7 +218,29 @@ function logError(scope, msg) {
|
|
|
217
218
|
// `file://${process.argv[1]}` produces an invalid URL because the path uses
|
|
218
219
|
// backslashes; pathToFileURL() correctly normalizes to a file:// URL on
|
|
219
220
|
// every platform, so the comparison is portable.
|
|
220
|
-
|
|
221
|
+
//
|
|
222
|
+
// `import.meta.url` is the REAL path of this file; `process.argv[1]` is the
|
|
223
|
+
// path as the host typed it. Through a symlink (plugin cache, global npm
|
|
224
|
+
// prefix) the two differ, so compare the typed path first — under
|
|
225
|
+
// `--preserve-symlinks-main` Node leaves `import.meta.url` unresolved too, and
|
|
226
|
+
// resolving the typed side unconditionally would make them diverge — and only
|
|
227
|
+
// then the resolved path. A realpath failure on that fallback is left to
|
|
228
|
+
// throw: a crash is visible, "not the entry point" is not.
|
|
229
|
+
//
|
|
230
|
+
// Also reads "not main" for `node -` (`argv[1]` is the literal `-`;
|
|
231
|
+
// `realpathSync('-')` would throw) and `node -e`/`-p` (the eval flag is in
|
|
232
|
+
// `process.execArgv`; `argv[1]` is just the caller's first argument). That
|
|
233
|
+
// is `isMain()`'s (scripts/lib/verify-core.mjs) `-`/eval handling and its
|
|
234
|
+
// typed-path-then-realpath order, without its directory case: `node <dir>`
|
|
235
|
+
// reads as "not main" here, where `isMain()` throws. The hook cannot import
|
|
236
|
+
// `isMain()`: it ships standalone (package.json `files`).
|
|
237
|
+
const EVAL_FLAG = /^(-e|-p|-pe|--eval|--print)(=|$)/;
|
|
238
|
+
const entryPath = process.argv[1];
|
|
239
|
+
const isMainModule = Boolean(entryPath)
|
|
240
|
+
&& entryPath !== '-'
|
|
241
|
+
&& !process.execArgv.some((arg) => EVAL_FLAG.test(arg))
|
|
242
|
+
&& (import.meta.url === pathToFileURL(entryPath).href
|
|
243
|
+
|| import.meta.url === pathToFileURL(realpathSync(entryPath)).href);
|
|
221
244
|
if (isMainModule) {
|
|
222
245
|
// See post-commit.js for why every exit path leaves a record (#327). This
|
|
223
246
|
// hook's only effect is the additionalContext it injects, so its outcome is
|
|
@@ -257,13 +280,20 @@ if (isMainModule) {
|
|
|
257
280
|
const prompt = data.prompt ?? data.user_prompt ?? '';
|
|
258
281
|
const updateDecision = await recordUpdateConsent(data.session_id, prompt);
|
|
259
282
|
const rememberIntent = detectRememberIntent(prompt);
|
|
260
|
-
|
|
283
|
+
// Messages waiting for the recipient this session declared in
|
|
284
|
+
// MEMESH_RECIPIENT. Read-only and not memory capture, so it is not
|
|
285
|
+
// gated by autoCapture. Empty when the variable is unset. An inbox that
|
|
286
|
+
// cannot be read is recorded as an `error` of its own (a label, never
|
|
287
|
+
// the message), next to whatever this prompt's outcome turns out to be,
|
|
288
|
+
// so a skip below cannot be read as "nothing was waiting".
|
|
289
|
+
const inboxLines = unreadMessageLines(process.env, (err) => record('error', `inbox: ${hookErrorReason(err)}`));
|
|
290
|
+
if (!rememberIntent && !updateDecision && inboxLines.length === 0) {
|
|
261
291
|
record('skipped', SKIP_REASONS.noPromptIntent);
|
|
262
292
|
return process.exit(0);
|
|
263
293
|
}
|
|
264
294
|
// Update consent is a user-authorized control decision, not memory
|
|
265
295
|
// capture; it must still be recorded when auto-capture is disabled.
|
|
266
|
-
if (!isAutoCaptureEnabled(process.env) && !updateDecision) {
|
|
296
|
+
if (!isAutoCaptureEnabled(process.env) && !updateDecision && inboxLines.length === 0) {
|
|
267
297
|
record('skipped', SKIP_REASONS.autoCaptureOff);
|
|
268
298
|
return process.exit(0);
|
|
269
299
|
}
|
|
@@ -276,10 +306,12 @@ if (isMainModule) {
|
|
|
276
306
|
} else if (updateDecision === 'never') {
|
|
277
307
|
contexts.push('The user asked never to be asked about MeMesh updates again. updateCheck is now off; do not mention updates. `memesh config set updateCheck true` turns checks back on.');
|
|
278
308
|
}
|
|
279
|
-
|
|
309
|
+
const hint = rememberIntent && isAutoCaptureEnabled(process.env);
|
|
310
|
+
if (hint) contexts.push(buildHint());
|
|
311
|
+
if (inboxLines.length > 0) contexts.push(inboxLines.join('\n'));
|
|
280
312
|
const out = { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: contexts.join('\n\n') } };
|
|
281
313
|
process.stdout.write(JSON.stringify(out));
|
|
282
|
-
record('notified', undefined, `hint:${updateDecision ?? 'remember-intent'}`);
|
|
314
|
+
record('notified', undefined, `hint:${updateDecision ?? (hint ? 'remember-intent' : 'inbox')}`);
|
|
283
315
|
process.exit(0);
|
|
284
316
|
} catch (err) {
|
|
285
317
|
logError('user-prompt-intent', err?.message || err);
|
|
@@ -497,14 +497,13 @@ fi
|
|
|
497
497
|
# that never contained them. `git archive` extracts exactly the tree at
|
|
498
498
|
# $MARKETPLACE_SHA; nothing untracked or uncommitted can reach the cache.
|
|
499
499
|
# node_modules and .git are never tracked, so they never appear in the
|
|
500
|
-
# archive; tests/benchmarks
|
|
501
|
-
#
|
|
502
|
-
# before.
|
|
500
|
+
# archive; tests/benchmarks are tracked (real source) and are removed after
|
|
501
|
+
# extraction to keep the shipped cache the same shape as before.
|
|
503
502
|
git -C "$MARKETPLACE_DIR" archive "$MARKETPLACE_SHA" | tar -x -C "$STAGE_PATH" || {
|
|
504
503
|
echo "ERROR: git archive failed — the live cache at $NEW_INSTALL_PATH was not touched" >&2
|
|
505
504
|
exit 1
|
|
506
505
|
}
|
|
507
|
-
if ! rm -rf "$STAGE_PATH/tests" "$STAGE_PATH/benchmarks"
|
|
506
|
+
if ! rm -rf "$STAGE_PATH/tests" "$STAGE_PATH/benchmarks"; then
|
|
508
507
|
echo "ERROR: could not remove development-only files from the staging copy — the live cache was not touched." >&2
|
|
509
508
|
exit 1
|
|
510
509
|
fi
|