claude-mem-lite 2.32.1 → 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.
@@ -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
+ }
@@ -0,0 +1,149 @@
1
+ // lib/task-reader.mjs — parse ~/.claude/tasks/<taskListId>/*.json for startup dashboard (T10a).
2
+ //
3
+ // Pure function over the filesystem. Filters to pending + in_progress tasks for a given project.
4
+ // Never throws — hooks must not break Claude Code. All I/O errors (ENOENT, permission denied,
5
+ // malformed JSON, races between readdir/stat) are silently skipped.
6
+ //
7
+ // Real schema observed in Claude Code ~/.claude/tasks/ (2026-04):
8
+ // - No `meta.json` files exist. Task dirs contain only `<taskId>.json` + hidden
9
+ // `.lock` / `.highwatermark` files.
10
+ // - Tasks use fields: `id`, `subject`, `activeForm`, `description`, `status`,
11
+ // `blocks`, `blockedBy`. Statuses: pending | in_progress | completed.
12
+ // - Project → taskListId mapping lives at `~/.claude/projects/<mangled>/<taskListId>/`
13
+ // where mangling = replace `/` with `-`.
14
+ //
15
+ // The v2.31 plan's fixture assumed a `meta.json`-based shape that is not how Claude Code
16
+ // actually organises tasks. This reader supports BOTH shapes so tests (which use meta.json
17
+ // per the plan) and runtime (which uses projectsRoot probing) both work:
18
+ //
19
+ // Priority 1 — fixture / future-proof: if `<dir>/meta.json` exists and contains
20
+ // `projectPath`, use it for project filtering.
21
+ // Priority 2 — real Claude Code: if `projectsRoot/<mangled>/<taskListId>/` exists,
22
+ // the task list belongs to `projectPath`.
23
+ // Priority 3 — no `projectPath` filter supplied: include all tasks.
24
+ //
25
+ // Normalized output uses `title` (falls back to `subject` → `'(untitled)'`) so T10c has
26
+ // a single contract regardless of source shape.
27
+
28
+ import { readdirSync, readFileSync, statSync } from 'fs';
29
+ import { join } from 'path';
30
+ import { homedir } from 'os';
31
+
32
+ const DEFAULT_TASKS_ROOT = join(homedir(), '.claude', 'tasks');
33
+ const DEFAULT_PROJECTS_ROOT = join(homedir(), '.claude', 'projects');
34
+ const ACTIVE_STATUSES = new Set(['pending', 'in_progress']);
35
+
36
+ /**
37
+ * Replace every non-alphanumeric character with `-`, mirroring Claude Code's
38
+ * `~/.claude/projects/<mangled>/` naming convention.
39
+ *
40
+ * Evidence from `~/.claude/projects/` listing (verified 2026-04):
41
+ * /mnt/data_ssd/dev/projects/mem → -mnt-data-ssd-dev-projects-mem (`/` and `_` → `-`)
42
+ * /home/sds/.claude/plugins/... → -home-sds--claude-plugins-... (leading `.` → `-`, so `/.` → `--`)
43
+ * /mnt/data/hdd/project / /agent → -mnt-data-hdd-project---agent (spaces/slashes → `-`)
44
+ *
45
+ * @param {string} p - Absolute project path.
46
+ * @returns {string} Mangled form suitable for `~/.claude/projects/<mangled>/`.
47
+ */
48
+ function manglePath(p) {
49
+ return String(p).replace(/[^a-zA-Z0-9]/g, '-');
50
+ }
51
+
52
+ /**
53
+ * Read active tasks (pending + in_progress) across all task lists that belong to a given
54
+ * project. Output is sorted by mtime DESC and capped at `maxTasks`.
55
+ *
56
+ * @param {object} [options]
57
+ * @param {string} [options.tasksRoot=~/.claude/tasks] - Override tasks root (testing).
58
+ * @param {string} [options.projectsRoot=~/.claude/projects] - Override projects root (testing).
59
+ * @param {string} [options.projectPath] - Absolute project path to filter by. When undefined,
60
+ * returns tasks from every task list encountered.
61
+ * @param {number} [options.maxTasks=20] - Cap on returned tasks.
62
+ * @returns {Array<{id:string,title:string,status:string,taskListId:string,mtime:number}>}
63
+ */
64
+ export function readProjectTasks({
65
+ tasksRoot = DEFAULT_TASKS_ROOT,
66
+ projectsRoot = DEFAULT_PROJECTS_ROOT,
67
+ projectPath,
68
+ maxTasks = 20,
69
+ } = {}) {
70
+ let listIds;
71
+ try {
72
+ listIds = readdirSync(tasksRoot);
73
+ } catch {
74
+ return [];
75
+ }
76
+
77
+ // Pre-compute the set of taskListIds registered under the project via projectsRoot
78
+ // (real Claude Code layout). Used as a fallback when meta.json is absent.
79
+ let projectListIds = null;
80
+ if (projectPath) {
81
+ try {
82
+ const mangled = manglePath(projectPath);
83
+ projectListIds = new Set(readdirSync(join(projectsRoot, mangled)));
84
+ } catch {
85
+ projectListIds = new Set();
86
+ }
87
+ }
88
+
89
+ const out = [];
90
+ outer: for (const id of listIds) {
91
+ const dir = join(tasksRoot, id);
92
+
93
+ // Project filter: meta.json (priority 1) → projectsRoot probe (priority 2) → all.
94
+ if (projectPath) {
95
+ let belongs;
96
+ try {
97
+ const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
98
+ belongs = meta && meta.projectPath === projectPath;
99
+ } catch {
100
+ // No meta.json (or malformed) — fall back to real Claude Code layout.
101
+ belongs = projectListIds.has(id);
102
+ }
103
+ if (!belongs) continue;
104
+ }
105
+
106
+ let entries;
107
+ try {
108
+ entries = readdirSync(dir);
109
+ } catch {
110
+ continue;
111
+ }
112
+
113
+ for (const f of entries) {
114
+ // Skip non-task files: meta.json, hidden .lock / .highwatermark, non-JSON.
115
+ if (f === 'meta.json') continue;
116
+ if (f.startsWith('.')) continue;
117
+ if (!f.endsWith('.json')) continue;
118
+
119
+ const filePath = join(dir, f);
120
+ let task;
121
+ try {
122
+ task = JSON.parse(readFileSync(filePath, 'utf8'));
123
+ } catch {
124
+ continue;
125
+ }
126
+ if (!task || !ACTIVE_STATUSES.has(task.status)) continue;
127
+
128
+ let mtime;
129
+ try {
130
+ mtime = statSync(filePath).mtimeMs;
131
+ } catch {
132
+ // Race: file disappeared between readdir and stat. Skip silently.
133
+ continue;
134
+ }
135
+
136
+ out.push({
137
+ id: task.id || f.replace(/\.json$/, ''),
138
+ // Normalize: plan's `title` → real Claude Code's `subject` → fallback literal.
139
+ title: task.title || task.subject || '(untitled)',
140
+ status: task.status,
141
+ taskListId: id,
142
+ mtime,
143
+ });
144
+ if (out.length >= maxTasks) break outer;
145
+ }
146
+ }
147
+
148
+ return out.sort((a, b) => b.mtime - a.mtime);
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.32.1",
3
+ "version": "2.32.2",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -35,10 +35,18 @@
35
35
  "hook-context.mjs",
36
36
  "hook-handoff.mjs",
37
37
  "hook-update.mjs",
38
+ "hook-optimize.mjs",
39
+ "plugin-cache-guard.mjs",
38
40
  "memdir.mjs",
39
41
  "adopt-content.mjs",
40
42
  "adopt-cli.mjs",
41
43
  "haiku-client.mjs",
44
+ "lib/activity.mjs",
45
+ "lib/git-state.mjs",
46
+ "lib/task-reader.mjs",
47
+ "lib/plan-reader.mjs",
48
+ "lib/startup-dashboard.mjs",
49
+ "lib/doctor-benchmark.mjs",
42
50
  "registry.mjs",
43
51
  "registry-retriever.mjs",
44
52
  "registry-indexer.mjs",
@@ -71,6 +79,8 @@
71
79
  "commands/tools.md",
72
80
  "commands/adopt.md",
73
81
  "commands/unadopt.md",
82
+ "commands/lesson.md",
83
+ "commands/bug.md",
74
84
  "hooks/hooks.json",
75
85
  "scripts/launch.mjs",
76
86
  "scripts/setup.sh",