claude-mem-lite 2.31.2 → 2.32.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/README.md +50 -0
- package/README.zh-CN.md +46 -0
- package/adopt-cli.mjs +166 -0
- package/adopt-content.mjs +75 -0
- package/commands/adopt.md +44 -0
- package/commands/bug.md +57 -0
- package/commands/lesson.md +53 -0
- package/commands/unadopt.md +30 -0
- package/hook-context.mjs +12 -5
- package/hook-optimize.mjs +724 -0
- package/hook-shared.mjs +31 -0
- package/install.mjs +27 -0
- package/lib/activity.mjs +114 -0
- package/lib/doctor-benchmark.mjs +153 -0
- package/lib/git-state.mjs +38 -0
- package/lib/plan-reader.mjs +43 -0
- package/lib/startup-dashboard.mjs +113 -0
- package/lib/task-reader.mjs +149 -0
- package/mem-cli.mjs +17 -0
- package/memdir.mjs +252 -0
- package/package.json +19 -1
- package/plugin-cache-guard.mjs +77 -0
- package/scripts/user-prompt-search.js +5 -1
- package/server-internals.mjs +48 -0
- package/server.mjs +3 -36
package/hook-shared.mjs
CHANGED
|
@@ -8,6 +8,10 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from '
|
|
|
8
8
|
import { inferProject, debugCatch } from './utils.mjs';
|
|
9
9
|
import { ensureDb, DB_DIR } from './schema.mjs';
|
|
10
10
|
import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared } from './haiku-client.mjs';
|
|
11
|
+
// Phase D: invited-memory sentinel detection. memdir.mjs only pulls in fs/path/os/crypto;
|
|
12
|
+
// adopt-content.mjs is pure strings. No circular deps — memdir doesn't import hook-shared.
|
|
13
|
+
import { memdirPath as _memdirPath, isAdopted as _isAdopted } from './memdir.mjs';
|
|
14
|
+
import { PLUGIN_SLUG as _PLUGIN_SLUG } from './adopt-content.mjs';
|
|
11
15
|
|
|
12
16
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
13
17
|
|
|
@@ -24,6 +28,33 @@ export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
|
|
|
24
28
|
export const RELATED_OBS_WINDOW_MS = 7 * 86400000; // 7 days
|
|
25
29
|
export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
|
|
26
30
|
|
|
31
|
+
// Phase A (v2.31.3+): MEM_QUIET_HOOKS=1 drops descriptive hook/MCP-instruction
|
|
32
|
+
// bodies (File Lessons / Key Context headers, MCP WHEN-TO-USE & decision rules,
|
|
33
|
+
// related-memory lesson suffix). Intended for users who adopted invited-memory
|
|
34
|
+
// (MEMORY.md sentinel) or who otherwise want minimal hook noise. Function form
|
|
35
|
+
// (not const) so modules importing at load time still respect later env sets
|
|
36
|
+
// in-process, and tests can toggle per-call. See docs/plans/2026-04-16-invited-memory-pattern.md.
|
|
37
|
+
export function isQuietHooks() {
|
|
38
|
+
return process.env.MEM_QUIET_HOOKS === '1';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Phase D (v2.32.1+): if the current project has adopted our invited-memory
|
|
42
|
+
// sentinel, MEMORY.md already carries the triggers at system-prompt authority
|
|
43
|
+
// — so hook + MCP-instruction output can also go quiet. isQuietHooks (env)
|
|
44
|
+
// remains an independent, stronger override.
|
|
45
|
+
export function isAdoptedHere(cwd) {
|
|
46
|
+
try {
|
|
47
|
+
const resolved = cwd || process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
|
|
48
|
+
return _isAdopted(_memdirPath(resolved), _PLUGIN_SLUG);
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function effectiveQuiet(cwd) {
|
|
55
|
+
return isQuietHooks() || isAdoptedHere(cwd);
|
|
56
|
+
}
|
|
57
|
+
|
|
27
58
|
// Handoff system constants
|
|
28
59
|
export const HANDOFF_EXPIRY_CLEAR = 6 * 3600000; // 6 hours (covers lunch/meeting breaks)
|
|
29
60
|
export const HANDOFF_EXPIRY_EXIT = 7 * 24 * 60 * 60 * 1000; // 7 days
|
package/install.mjs
CHANGED
|
@@ -224,6 +224,10 @@ async function install() {
|
|
|
224
224
|
'lib/plan-reader.mjs',
|
|
225
225
|
'lib/git-state.mjs',
|
|
226
226
|
'lib/startup-dashboard.mjs',
|
|
227
|
+
// v2.32 (invited-memory): memdir primitives + adopt/unadopt CLI.
|
|
228
|
+
'memdir.mjs',
|
|
229
|
+
'adopt-content.mjs',
|
|
230
|
+
'adopt-cli.mjs',
|
|
227
231
|
];
|
|
228
232
|
|
|
229
233
|
if (IS_DEV) {
|
|
@@ -807,6 +811,25 @@ async function install() {
|
|
|
807
811
|
log('No existing database — will be created on first use');
|
|
808
812
|
}
|
|
809
813
|
|
|
814
|
+
// 7b. Dogfood auto-adopt (invited-memory, Phase C T13).
|
|
815
|
+
// Only fires when install.mjs is running from the claude-mem-lite source repo
|
|
816
|
+
// itself (detected via git remote match). In npm/npx flows PROJECT_DIR is a
|
|
817
|
+
// cache dir with no git metadata, so this is a no-op for end users.
|
|
818
|
+
// --no-adopt override respected.
|
|
819
|
+
if (!flags.has('--no-adopt')) {
|
|
820
|
+
try {
|
|
821
|
+
const remote = execFileSync('git', ['-C', PROJECT_DIR, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
822
|
+
const isDogfood = /github\.com[:/]sdsrss\/claude-mem-lite(\.git)?$/i.test(remote);
|
|
823
|
+
if (isDogfood) {
|
|
824
|
+
const { cmdAdopt } = await import('./adopt-cli.mjs');
|
|
825
|
+
cmdAdopt([]);
|
|
826
|
+
ok('Invited-memory: auto-adopt for claude-mem-lite dogfood repo');
|
|
827
|
+
}
|
|
828
|
+
} catch {
|
|
829
|
+
// Not a git repo, or git missing — silent skip (this is the normal npm path).
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
810
833
|
// 8. Disable old claude-mem plugin
|
|
811
834
|
if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
|
|
812
835
|
settings.enabledPlugins['claude-mem@thedotmack'] = false;
|
|
@@ -851,6 +874,10 @@ async function uninstall() {
|
|
|
851
874
|
const settings = readSettings();
|
|
852
875
|
cleanupMemHooksFromSettings(settings);
|
|
853
876
|
|
|
877
|
+
// 2b. Uninstall does NOT auto-unadopt — an adopted project may be in active use
|
|
878
|
+
// by the user in other Claude Code sessions. Tell them the command instead.
|
|
879
|
+
log('Invited-memory: adopt state preserved. Run `claude-mem-lite unadopt --all` to remove sentinel sections.');
|
|
880
|
+
|
|
854
881
|
// 3. Clean plugin registry entries conservatively (avoid deleting other plugins
|
|
855
882
|
// from the same marketplace publisher)
|
|
856
883
|
const pluginsDir = join(homedir(), '.claude', 'plugins');
|
package/lib/activity.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// lib/activity.mjs — activity namespace data layer (T7 v2.31)
|
|
2
|
+
// Pure functions over the events table. No I/O beyond the passed-in db handle.
|
|
3
|
+
//
|
|
4
|
+
// Activity events are NOT memdir-compatible types; they live here precisely
|
|
5
|
+
// so they don't pollute the L1 system-prompt memory section.
|
|
6
|
+
|
|
7
|
+
import { sanitizeFtsQuery } from '../utils.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Canonical event_type enum — mirrors the events.event_type CHECK constraint.
|
|
11
|
+
* Single source of truth for CLI validation, hook-llm (future T9), and any
|
|
12
|
+
* other caller that needs to guard against invalid types before INSERT.
|
|
13
|
+
* Order matches the DDL; frozen to prevent accidental mutation.
|
|
14
|
+
*/
|
|
15
|
+
export const EVENT_TYPES = Object.freeze([
|
|
16
|
+
'bugfix',
|
|
17
|
+
'lesson',
|
|
18
|
+
'bug',
|
|
19
|
+
'discovery',
|
|
20
|
+
'refactor',
|
|
21
|
+
'feature',
|
|
22
|
+
'observation',
|
|
23
|
+
'decision',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Insert one event. Returns the new id (Number cast from BigInt).
|
|
28
|
+
*
|
|
29
|
+
* @param {object} db better-sqlite3 handle
|
|
30
|
+
* @param {object} params
|
|
31
|
+
* @param {string} params.project
|
|
32
|
+
* @param {string} params.event_type one of the CHECK-constrained enum values
|
|
33
|
+
* @param {string} params.title
|
|
34
|
+
* @param {string|null} [params.body]
|
|
35
|
+
* @param {string[]|null} [params.file_paths] stored as JSON array
|
|
36
|
+
* @param {string|null} [params.git_sha]
|
|
37
|
+
* @param {number} [params.importance=1]
|
|
38
|
+
* @param {number} [params.created_at_epoch=Date.now()]
|
|
39
|
+
* @returns {number} lastInsertRowid
|
|
40
|
+
*/
|
|
41
|
+
export function saveEvent(db, {
|
|
42
|
+
project,
|
|
43
|
+
event_type,
|
|
44
|
+
title,
|
|
45
|
+
body = null,
|
|
46
|
+
file_paths = null,
|
|
47
|
+
git_sha = null,
|
|
48
|
+
importance = 1,
|
|
49
|
+
created_at_epoch = Date.now(),
|
|
50
|
+
}) {
|
|
51
|
+
const info = db.prepare(`
|
|
52
|
+
INSERT INTO events (project, event_type, title, body, file_paths, git_sha, importance, created_at_epoch)
|
|
53
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
54
|
+
`).run(
|
|
55
|
+
project,
|
|
56
|
+
event_type,
|
|
57
|
+
title,
|
|
58
|
+
body,
|
|
59
|
+
file_paths ? JSON.stringify(file_paths) : null,
|
|
60
|
+
git_sha,
|
|
61
|
+
importance,
|
|
62
|
+
created_at_epoch,
|
|
63
|
+
);
|
|
64
|
+
return Number(info.lastInsertRowid);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetch one event by id, bumping accessed_count + last_accessed_epoch.
|
|
69
|
+
* Returns the row (access bump already applied) or undefined.
|
|
70
|
+
*/
|
|
71
|
+
export function getEvent(db, id) {
|
|
72
|
+
db.prepare(`UPDATE events SET accessed_count = accessed_count + 1, last_accessed_epoch = ? WHERE id = ?`)
|
|
73
|
+
.run(Date.now(), id);
|
|
74
|
+
return db.prepare(`SELECT * FROM events WHERE id = ?`).get(id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* FTS5 search filtered by project (and optionally event_type).
|
|
79
|
+
* Excludes superseded events. Returns up to `limit` rows ordered by FTS rank.
|
|
80
|
+
*/
|
|
81
|
+
export function searchEvents(db, query, { project, type = null, limit = 10 } = {}) {
|
|
82
|
+
const q = sanitizeFtsQuery(query);
|
|
83
|
+
if (!q) return [];
|
|
84
|
+
const typeClause = type ? 'AND e.event_type = ?' : '';
|
|
85
|
+
const sql = `
|
|
86
|
+
SELECT e.*
|
|
87
|
+
FROM events_fts
|
|
88
|
+
JOIN events e ON e.id = events_fts.rowid
|
|
89
|
+
WHERE events_fts MATCH ?
|
|
90
|
+
AND e.project = ?
|
|
91
|
+
AND e.superseded_at_epoch IS NULL
|
|
92
|
+
${typeClause}
|
|
93
|
+
ORDER BY events_fts.rank
|
|
94
|
+
LIMIT ?
|
|
95
|
+
`;
|
|
96
|
+
const params = type ? [q, project, type, limit] : [q, project, limit];
|
|
97
|
+
return db.prepare(sql).all(...params);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Most recent N events for a project (excluding superseded).
|
|
102
|
+
* Uses idx_events_project_created (T6.1) — index-only sort, no temp B-tree.
|
|
103
|
+
*/
|
|
104
|
+
export function recentEvents(db, { project, type = null, limit = 20 } = {}) {
|
|
105
|
+
const typeClause = type ? 'AND event_type = ?' : '';
|
|
106
|
+
const sql = `
|
|
107
|
+
SELECT * FROM events
|
|
108
|
+
WHERE project = ? AND superseded_at_epoch IS NULL ${typeClause}
|
|
109
|
+
ORDER BY created_at_epoch DESC
|
|
110
|
+
LIMIT ?
|
|
111
|
+
`;
|
|
112
|
+
const params = type ? [project, type, limit] : [project, limit];
|
|
113
|
+
return db.prepare(sql).all(...params);
|
|
114
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Baseline benchmark capture for v2.31 MVP.
|
|
2
|
+
// Measures (bounded scope):
|
|
3
|
+
// - L2 MCP instructions byte count (cost-per-turn source).
|
|
4
|
+
// - Count of registered MCP tool schemas.
|
|
5
|
+
// - Hook execution latency p50/p99 by replaying a prompt fixture.
|
|
6
|
+
// - Hook injection rate (fraction of prompts that would inject non-empty output).
|
|
7
|
+
//
|
|
8
|
+
// This is a *static* analyzer plus a DB-driven simulator — it does not spawn
|
|
9
|
+
// the MCP server or the hook process. See docs/plans/2026-04-14-mem-v2.31-mvp.md
|
|
10
|
+
// (Task 1). Out of scope: prompt cache hit/miss.
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'fs';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import { dirname, join } from 'path';
|
|
15
|
+
import { sanitizeFtsQuery, OBS_BM25 } from '../utils.mjs';
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const SERVER_PATH = join(__dirname, '..', 'server.mjs');
|
|
19
|
+
const SERVER_INTERNALS_PATH = join(__dirname, '..', 'server-internals.mjs');
|
|
20
|
+
const BENCHMARK_VERSION = '1';
|
|
21
|
+
|
|
22
|
+
function extractStringArrayBody(body) {
|
|
23
|
+
const parts = [];
|
|
24
|
+
const re = /(['"])((?:\\.|(?!\1).)*)\1/g;
|
|
25
|
+
let m;
|
|
26
|
+
while ((m = re.exec(body)) !== null) {
|
|
27
|
+
const unescaped = m[2]
|
|
28
|
+
.replace(/\\n/g, '\n')
|
|
29
|
+
.replace(/\\'/g, "'")
|
|
30
|
+
.replace(/\\"/g, '"')
|
|
31
|
+
.replace(/\\\\/g, '\\');
|
|
32
|
+
parts.push(unescaped);
|
|
33
|
+
}
|
|
34
|
+
return parts;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract the string body of the MCP `instructions:` field.
|
|
39
|
+
* Supports three forms:
|
|
40
|
+
* 1. template literal in server.mjs: instructions: `...`
|
|
41
|
+
* 2. array-join in server.mjs: instructions: [ '...', '...' ].join('\n')
|
|
42
|
+
* 3. (v2.31.3+) builder call in server.mjs referencing INSTRUCTIONS_BASE +
|
|
43
|
+
* INSTRUCTIONS_VERBOSE arrays in server-internals.mjs. Measured at the
|
|
44
|
+
* verbose form — this is the cost-per-turn baseline the benchmark tracks.
|
|
45
|
+
* Returns '' if no shape matches (caller treats byte count as 0).
|
|
46
|
+
*/
|
|
47
|
+
function readMcpInstructions() {
|
|
48
|
+
const src = readFileSync(SERVER_PATH, 'utf8');
|
|
49
|
+
|
|
50
|
+
// Form 1: template literal
|
|
51
|
+
const tmpl = src.match(/instructions:\s*`([\s\S]*?)`/);
|
|
52
|
+
if (tmpl) return tmpl[1];
|
|
53
|
+
|
|
54
|
+
// Form 2: string array + .join(...)
|
|
55
|
+
const arr = src.match(/instructions:\s*\[([\s\S]*?)\]\s*\.join\(/);
|
|
56
|
+
if (arr) return extractStringArrayBody(arr[1]).join('\n');
|
|
57
|
+
|
|
58
|
+
// Form 3: buildServerInstructions() — reconstruct verbose form from
|
|
59
|
+
// server-internals.mjs INSTRUCTIONS_BASE + INSTRUCTIONS_VERBOSE arrays.
|
|
60
|
+
if (/instructions:\s*buildServerInstructions\(/.test(src)) {
|
|
61
|
+
let internals;
|
|
62
|
+
try { internals = readFileSync(SERVER_INTERNALS_PATH, 'utf8'); } catch { return ''; }
|
|
63
|
+
const base = internals.match(/INSTRUCTIONS_BASE\s*=\s*\[([\s\S]*?)\];/);
|
|
64
|
+
const verbose = internals.match(/INSTRUCTIONS_VERBOSE\s*=\s*\[([\s\S]*?)\];/);
|
|
65
|
+
const parts = [];
|
|
66
|
+
if (base) parts.push(...extractStringArrayBody(base[1]));
|
|
67
|
+
if (verbose) parts.push(...extractStringArrayBody(verbose[1]));
|
|
68
|
+
return parts.join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function countMcpTools() {
|
|
75
|
+
const src = readFileSync(SERVER_PATH, 'utf8');
|
|
76
|
+
return (src.match(/server\.registerTool\(/g) || []).length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function percentile(sortedAsc, p) {
|
|
80
|
+
if (sortedAsc.length === 0) return null;
|
|
81
|
+
const idx = Math.min(sortedAsc.length - 1, Math.floor(sortedAsc.length * p));
|
|
82
|
+
return sortedAsc[idx];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {import('better-sqlite3').Database} db
|
|
87
|
+
* @param {{prompts?: string[], project?: string, skipHookLatency?: boolean}} options
|
|
88
|
+
*/
|
|
89
|
+
export function runBenchmark(db, { prompts = [], project = 'mem', skipHookLatency = false } = {}) {
|
|
90
|
+
const instructions = readMcpInstructions();
|
|
91
|
+
const mcp_instructions_bytes = Buffer.byteLength(instructions, 'utf8');
|
|
92
|
+
const mcp_tool_count = countMcpTools();
|
|
93
|
+
|
|
94
|
+
// Prepare the injection probe once; reused across the rate loop and the
|
|
95
|
+
// latency loop. Re-preparing per iteration would inflate p50/p99 by the
|
|
96
|
+
// prepare-overhead, masking real hook latency.
|
|
97
|
+
const injectionStmt = db.prepare(`
|
|
98
|
+
SELECT ${OBS_BM25} AS score
|
|
99
|
+
FROM observations_fts
|
|
100
|
+
JOIN observations o ON o.id = observations_fts.rowid
|
|
101
|
+
WHERE observations_fts MATCH ? AND o.project = ?
|
|
102
|
+
ORDER BY score LIMIT 1
|
|
103
|
+
`);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Simulate the memory-inject hook for one prompt:
|
|
107
|
+
* - trim-and-length guard (mirrors hook-memory.mjs minimum-length heuristic)
|
|
108
|
+
* - sanitize as FTS query
|
|
109
|
+
* - BM25-ranked lookup over observations_fts, filtered by project
|
|
110
|
+
* Returns true iff the simulator would produce a non-empty injection.
|
|
111
|
+
*/
|
|
112
|
+
const runInjection = (promptText) => {
|
|
113
|
+
if (!promptText || promptText.trim().length < 15) return false;
|
|
114
|
+
const q = sanitizeFtsQuery(promptText);
|
|
115
|
+
if (!q) return false;
|
|
116
|
+
try {
|
|
117
|
+
return !!injectionStmt.get(q, project);
|
|
118
|
+
} catch {
|
|
119
|
+
// Malformed query slipped past sanitize → treat as no injection.
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
let injected = 0;
|
|
125
|
+
for (const p of prompts) {
|
|
126
|
+
if (runInjection(p)) injected++;
|
|
127
|
+
}
|
|
128
|
+
const injection_rate = prompts.length > 0 ? injected / prompts.length : 0;
|
|
129
|
+
|
|
130
|
+
let hook_p50_ms = null;
|
|
131
|
+
let hook_p99_ms = null;
|
|
132
|
+
if (!skipHookLatency && prompts.length > 0) {
|
|
133
|
+
const latencies = [];
|
|
134
|
+
for (const p of prompts) {
|
|
135
|
+
const t0 = performance.now();
|
|
136
|
+
runInjection(p);
|
|
137
|
+
latencies.push(performance.now() - t0);
|
|
138
|
+
}
|
|
139
|
+
latencies.sort((a, b) => a - b);
|
|
140
|
+
hook_p50_ms = percentile(latencies, 0.5);
|
|
141
|
+
hook_p99_ms = percentile(latencies, 0.99);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
version: BENCHMARK_VERSION,
|
|
146
|
+
mcp_tool_count,
|
|
147
|
+
mcp_instructions_bytes,
|
|
148
|
+
prompt_count: prompts.length,
|
|
149
|
+
injection_rate,
|
|
150
|
+
hook_p50_ms,
|
|
151
|
+
hook_p99_ms,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// lib/git-state.mjs — thin wrapper around git status/stash/HEAD sha (T10b).
|
|
2
|
+
// Used by startup-dashboard (T10c) and continuation-anchor detection (T10d).
|
|
3
|
+
// All calls are timeout-bounded; any failure yields empty fields, never throws.
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
|
|
7
|
+
const GIT_TIMEOUT_MS = 1500;
|
|
8
|
+
|
|
9
|
+
function run(cmd, args, { cwd } = {}) {
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync(cmd, args, {
|
|
12
|
+
cwd,
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
timeout: GIT_TIMEOUT_MS,
|
|
15
|
+
// Suppress git's own stderr noise (e.g. "fatal: not a git repository").
|
|
16
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
17
|
+
}).trim();
|
|
18
|
+
} catch {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Return a compact snapshot of git state. Safe on non-git directories.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} [options]
|
|
27
|
+
* @param {string} [options.cwd=process.cwd()]
|
|
28
|
+
* @returns {{changed: string[], stashes: string[], branch: string|null, headSha: string|null}}
|
|
29
|
+
*/
|
|
30
|
+
export function readGitState({ cwd = process.cwd() } = {}) {
|
|
31
|
+
const statusOut = run('git', ['status', '--porcelain'], { cwd });
|
|
32
|
+
const changed = statusOut ? statusOut.split('\n').filter(Boolean) : [];
|
|
33
|
+
const stashOut = run('git', ['stash', 'list'], { cwd });
|
|
34
|
+
const stashes = stashOut ? stashOut.split('\n').filter(Boolean) : [];
|
|
35
|
+
const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }) || null;
|
|
36
|
+
const headSha = run('git', ['rev-parse', 'HEAD'], { cwd }) || null;
|
|
37
|
+
return { changed, stashes, branch, headSha };
|
|
38
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// lib/plan-reader.mjs — list recent plan files under ~/.claude/plans/ (T10b).
|
|
2
|
+
// For startup-dashboard (T10c); pure function; silent on I/O errors.
|
|
3
|
+
//
|
|
4
|
+
// Real schema observed in Claude Code ~/.claude/plans/ (2026-04):
|
|
5
|
+
// Flat directory of *.md files at root level. No nesting by project.
|
|
6
|
+
// Example: `imperative-baking-hamster.md`, `zippy-frolicking-liskov.md`.
|
|
7
|
+
|
|
8
|
+
import { readdirSync, statSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PLANS_ROOT = join(homedir(), '.claude', 'plans');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return up to `limit` most recently modified .md plan files.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {string} [options.plansRoot=~/.claude/plans] - Override root (testing).
|
|
19
|
+
* @param {number} [options.limit=5]
|
|
20
|
+
* @returns {Array<{name:string, path:string, mtime:number}>} name is the basename without .md
|
|
21
|
+
*/
|
|
22
|
+
export function recentPlans({ plansRoot = DEFAULT_PLANS_ROOT, limit = 5 } = {}) {
|
|
23
|
+
let files;
|
|
24
|
+
try {
|
|
25
|
+
files = readdirSync(plansRoot);
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return files
|
|
30
|
+
.filter(f => f.endsWith('.md'))
|
|
31
|
+
.map(f => {
|
|
32
|
+
let mtime = 0;
|
|
33
|
+
try {
|
|
34
|
+
mtime = statSync(join(plansRoot, f)).mtimeMs;
|
|
35
|
+
} catch {
|
|
36
|
+
// Race: file vanished between readdir and stat. Keep entry with mtime=0
|
|
37
|
+
// so it sorts last; caller sees a stable name at worst.
|
|
38
|
+
}
|
|
39
|
+
return { name: f.replace(/\.md$/, ''), path: join(plansRoot, f), mtime };
|
|
40
|
+
})
|
|
41
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
42
|
+
.slice(0, limit);
|
|
43
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// lib/startup-dashboard.mjs — aggregates git/tasks/plans/handoff/events stats
|
|
2
|
+
// into a single SessionStart injection line (T10c v2.31).
|
|
3
|
+
//
|
|
4
|
+
// Pure function (with injectable stubs for test determinism). Never throws:
|
|
5
|
+
// every external reader is try/catch'd so a broken git repo, missing tasks dir,
|
|
6
|
+
// or absent events table cannot break SessionStart.
|
|
7
|
+
|
|
8
|
+
import { readGitState } from './git-state.mjs';
|
|
9
|
+
import { readProjectTasks } from './task-reader.mjs';
|
|
10
|
+
import { recentPlans } from './plan-reader.mjs';
|
|
11
|
+
import { isAdoptedHere } from '../hook-shared.mjs';
|
|
12
|
+
|
|
13
|
+
function ageStr(ms) {
|
|
14
|
+
const diff = Date.now() - ms;
|
|
15
|
+
const mins = Math.floor(diff / 60000);
|
|
16
|
+
if (mins < 1) return 'just now';
|
|
17
|
+
if (mins < 60) return `${mins}m ago`;
|
|
18
|
+
const hrs = Math.floor(mins / 60);
|
|
19
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
20
|
+
return `${Math.floor(hrs / 24)}d ago`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readRecentHandoff(db, project) {
|
|
24
|
+
try {
|
|
25
|
+
return db.prepare(`
|
|
26
|
+
SELECT created_at_epoch, working_on FROM session_handoffs
|
|
27
|
+
WHERE project = ? AND type = 'exit'
|
|
28
|
+
ORDER BY created_at_epoch DESC LIMIT 1
|
|
29
|
+
`).get(project);
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the dashboard injection string, or empty string when all sources empty.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} options
|
|
37
|
+
* @param {object} options.db - better-sqlite3 handle. May be null.
|
|
38
|
+
* @param {string} options.project - project identifier used for handoff/events queries.
|
|
39
|
+
* @param {string} [options.projectPath=process.cwd()] - filesystem root for git + tasks.
|
|
40
|
+
* @param {object} [options.stubs] - per-field test injectors:
|
|
41
|
+
* {git?, tasks?, plans?, handoff?}. handoff: pass explicit null to skip;
|
|
42
|
+
* omit to fall through to DB query.
|
|
43
|
+
* @returns {string} rendered dashboard or '' if no content.
|
|
44
|
+
*/
|
|
45
|
+
export function buildDashboard({ db, project, projectPath = process.cwd(), stubs = null } = {}) {
|
|
46
|
+
const git = stubs?.git ?? readGitState({ cwd: projectPath });
|
|
47
|
+
const tasks = stubs?.tasks ?? readProjectTasks({ projectPath });
|
|
48
|
+
const plans = stubs?.plans ?? recentPlans({ limit: 3 });
|
|
49
|
+
// stubs.handoff === null means "test asserted no handoff"; undefined means "query DB".
|
|
50
|
+
const handoff = stubs && 'handoff' in stubs ? stubs.handoff : readRecentHandoff(db, project);
|
|
51
|
+
|
|
52
|
+
let eventCount = 0;
|
|
53
|
+
try {
|
|
54
|
+
eventCount = db?.prepare(`SELECT COUNT(*) c FROM events WHERE project = ?`).get(project)?.c ?? 0;
|
|
55
|
+
} catch { /* events table may not exist on very old DBs */ }
|
|
56
|
+
|
|
57
|
+
const parts = [];
|
|
58
|
+
|
|
59
|
+
if ((git?.changed?.length ?? 0) > 0 || (git?.stashes?.length ?? 0) > 0) {
|
|
60
|
+
parts.push('📊 Git:');
|
|
61
|
+
if (git.changed.length > 0) {
|
|
62
|
+
parts.push(` - ${git.changed.length} uncommitted file(s) on ${git.branch || 'HEAD'}`);
|
|
63
|
+
}
|
|
64
|
+
if (git.stashes.length > 0) {
|
|
65
|
+
parts.push(` - ${git.stashes.length} stash(es)`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (tasks.length > 0) {
|
|
70
|
+
parts.push('📋 Active tasks:');
|
|
71
|
+
for (const t of tasks.slice(0, 3)) {
|
|
72
|
+
parts.push(` - [${t.status}] ${t.title}`);
|
|
73
|
+
}
|
|
74
|
+
if (tasks.length > 3) parts.push(` - (… +${tasks.length - 3} more)`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (plans.length > 0) {
|
|
78
|
+
parts.push('📝 Recent plans:');
|
|
79
|
+
for (const p of plans.slice(0, 2)) {
|
|
80
|
+
parts.push(` - ${p.name} (${ageStr(p.mtime)})`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (handoff) {
|
|
85
|
+
parts.push(`🔄 Continuation (/exit ${ageStr(handoff.created_at_epoch)}):`);
|
|
86
|
+
if (handoff.working_on) {
|
|
87
|
+
parts.push(` - Working on: ${handoff.working_on.slice(0, 160)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (eventCount > 0) {
|
|
92
|
+
parts.push(`💡 mem events: ${eventCount} entries (\`claude-mem-lite activity recent\`)`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Invited-memory hint (Phase F): persists every SessionStart until the user
|
|
96
|
+
// adopts. Self-clearing — once the sentinel exists, isAdoptedHere() flips to
|
|
97
|
+
// true and this line silently drops. MEM_NO_ADOPT_HINT=1 opts out permanently
|
|
98
|
+
// for users who prefer the verbose hook layer.
|
|
99
|
+
const adoptHint = buildAdoptHint({ projectPath, stubs });
|
|
100
|
+
if (adoptHint) parts.push(adoptHint);
|
|
101
|
+
|
|
102
|
+
if (parts.length === 0) return '';
|
|
103
|
+
return `[mem] Startup dashboard:\n${parts.join('\n')}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildAdoptHint({ projectPath, stubs }) {
|
|
107
|
+
if (process.env.MEM_NO_ADOPT_HINT === '1') return null;
|
|
108
|
+
if (process.env.MEM_QUIET_HOOKS === '1') return null;
|
|
109
|
+
// stubs.adopted === true/false lets tests assert both branches without touching FS.
|
|
110
|
+
const adopted = stubs && 'adopted' in stubs ? stubs.adopted : isAdoptedHere(projectPath);
|
|
111
|
+
if (adopted) return null;
|
|
112
|
+
return '🧷 Invited-memory 未启用:`claude-mem-lite adopt` 注入 MEMORY.md → 上下文省 ~40%,MCP 调用率提升;静音 `MEM_NO_ADOPT_HINT=1`';
|
|
113
|
+
}
|