memoir-cli 3.16.2 → 3.16.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.16.2",
3
+ "version": "3.16.4",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "Portable, project-scoped memory and session handoffs for coding agents. Readable files, MCP recall, and optional user-passphrase encrypted backups.",
6
6
  "main": "src/index.js",
@@ -8,6 +8,7 @@ import os from 'os';
8
8
  import chalk from 'chalk';
9
9
  import { shouldIgnoreProject } from '../context/capture.js';
10
10
  import { vscodeUserDir, vscodeGlobalStorage, xdgConfigDir } from '../utils/platform.js';
11
+ import { isSideCarDir } from '../context/transcripts.js';
11
12
 
12
13
  const home = os.homedir();
13
14
 
@@ -57,8 +58,8 @@ export const adapters = [
57
58
  // Allow directory traversal but skip dirs that only contain session data
58
59
  try {
59
60
  if (nodeFs.statSync(src).isDirectory()) {
60
- // Skip subagents and UUID-named session dirs (contain large .jsonl files, no .md)
61
- if (basename === 'subagents') return false;
61
+ // Skip agent side-car and UUID-named session dirs (contain large .jsonl files, no .md)
62
+ if (isSideCarDir(basename)) return false;
62
63
  // UUID pattern: 8-4-4-4-12 hex chars
63
64
  if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(basename)) return false;
64
65
  return true;
@@ -8,6 +8,7 @@ import gradient from 'gradient-string';
8
8
  import { getConfig, getGeminiApiKey } from '../config.js';
9
9
  import { syncToLocal, syncToGit } from '../providers/index.js';
10
10
  import { saveHandoff } from '../context/handoffs.js';
11
+ import { isSideCarDir, isUserTranscript } from '../context/transcripts.js';
11
12
 
12
13
  const home = os.homedir();
13
14
 
@@ -22,8 +23,12 @@ function findClaudeSessions() {
22
23
  for (const entry of entries) {
23
24
  const full = path.join(dir, entry.name);
24
25
  if (entry.isDirectory()) {
26
+ // Same side-car directories capture.js refuses. Without this, every
27
+ // agent-<id>.jsonl under subagents/ is collected and its orchestrator
28
+ // prompt is sent to the summariser as "what they asked for".
29
+ if (isSideCarDir(entry.name)) continue;
25
30
  scanDir(full);
26
- } else if (entry.name.endsWith('.jsonl') && !entry.name.includes('subagent')) {
31
+ } else if (isUserTranscript(entry.name)) {
27
32
  const stat = fs.statSync(full);
28
33
  sessions.push({ path: full, mtime: stat.mtimeMs, size: stat.size });
29
34
  }
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import { execFileSync } from 'child_process';
5
5
  import { scanForSecrets, redactSecrets } from '../security/scanner.js';
6
+ import { isSideCarDir, isUserTranscript } from './transcripts.js';
6
7
 
7
8
  const home = os.homedir();
8
9
 
@@ -47,9 +48,9 @@ export function findClaudeSessions() {
47
48
  // agent-<id>.jsonl, not *subagent*), so USER_NOTE_RE minted
48
49
  // decisions out of system prompts. Live proof: three of the
49
50
  // author's own pinned decisions were subagent-prompt fragments.
50
- if (entry.name === 'subagents' || entry.name === 'workflows' || entry.name === 'tool-results') continue;
51
+ if (isSideCarDir(entry.name)) continue;
51
52
  scanDir(full);
52
- } else if (entry.name.endsWith('.jsonl') && !entry.name.includes('subagent') && !entry.name.startsWith('agent-')) {
53
+ } else if (isUserTranscript(entry.name)) {
53
54
  try {
54
55
  const stat = fs.statSync(full);
55
56
  // Skip files older than 7 days for performance
@@ -0,0 +1,42 @@
1
+ // One definition of "this .jsonl is an agent transcript, not the user talking."
2
+ //
3
+ // Claude Code writes subagent transcripts as `agent-<id>.jsonl` inside a
4
+ // `subagents/` directory. The FIRST user message in one of those files is the
5
+ // orchestrator's prompt ("You are a software architect. Note that ..."), not
6
+ // anything a person typed. Treating them as user speech has already produced
7
+ // two distinct failures in this codebase:
8
+ //
9
+ // 1. src/context/capture.js minted pinned "decisions" out of system prompts —
10
+ // three of the author's own were subagent-prompt fragments.
11
+ // 2. src/commands/snapshot.js collected them into a handoff summary whose
12
+ // prompt is labelled "User messages (what they asked for)" and POSTed to
13
+ // generativelanguage.googleapis.com, so orchestrator prompts left the
14
+ // machine entirely.
15
+ //
16
+ // Both were written as `!name.includes('subagent')`, which never matches
17
+ // `agent-<id>.jsonl` — the substring is "agent-", not "subagent". capture.js
18
+ // was fixed in place; snapshot.js kept the defeated test for three more weeks
19
+ // because the predicate was spelled four different ways across three files.
20
+ // It is spelled once here now.
21
+
22
+ /** Per-session side directories that never contain user speech. */
23
+ const SIDE_CAR_DIRS = new Set(['subagents', 'workflows', 'tool-results']);
24
+
25
+ /** True for a directory that holds agent-generated transcripts, not user turns. */
26
+ export function isSideCarDir(name) {
27
+ return SIDE_CAR_DIRS.has(name);
28
+ }
29
+
30
+ /**
31
+ * True for a .jsonl file that is an agent transcript rather than a user
32
+ * session. Covers both the real filename (`agent-<id>.jsonl`) and the older
33
+ * `*subagent*` spelling, so a directory walk that reaches one anyway is safe.
34
+ */
35
+ export function isAgentTranscript(name) {
36
+ return name.startsWith('agent-') || name.includes('subagent');
37
+ }
38
+
39
+ /** True for a .jsonl file safe to read as a record of what the user asked for. */
40
+ export function isUserTranscript(name) {
41
+ return name.endsWith('.jsonl') && !isAgentTranscript(name);
42
+ }
package/src/mcp.js CHANGED
@@ -483,15 +483,28 @@ server.tool(
483
483
 
484
484
  server.tool(
485
485
  'memoir_session',
486
- 'Show the current session state — goals, next actions, open questions, recent decisions, recent sessions across machines. Use this to catch up at the start of a session, or when you need to orient yourself on what was decided.',
487
- {},
488
- async () => {
489
- const state = sessionView(await readSession());
486
+ 'Show the current session state — goals, next actions, open questions, recent decisions, recent sessions across machines. Use this to catch up at the start of a session, or when you need to orient yourself on what was decided. Pass project to narrow it to one project, or tag to narrow it to items whose text carries a [tag] prefix.',
487
+ {
488
+ project: z.string().optional().describe('Project directory or identity. Defaults to the current working project.'),
489
+ tag: z.string().optional().describe('Show only items whose text starts with a bracketed tag, e.g. "gathered" matches "[gathered — CAM] …". Case-insensitive.'),
490
+ },
491
+ async ({ project, tag }) => {
492
+ const state = sessionView(await readSession(), project ? { project } : {});
490
493
  const machine = await getMachineId();
491
- const goals = state.current.goals.map(g => `- ${g.text}`).join('\n') || '(none)';
492
- const nexts = state.current.next_actions.map(n => `- [ ] ${n.text}`).join('\n') || '(none)';
493
- const questions = state.current.open_questions.map(q => `- ${q.text}`).join('\n') || '(none)';
494
- const decisions = state.current.decisions.filter(d => visibleMemory(d)).slice(0, 5).map(d => {
494
+ // Untagged items are visible in every project by design; a [tag] prefix in
495
+ // the text is the only project signal many sessions actually carry.
496
+ const tagged = items => {
497
+ if (!tag) return items;
498
+ const want = tag.toLowerCase().replace(/^\[|\]$/g, '');
499
+ return items.filter(i => {
500
+ const m = String(i?.text || '').match(/^\s*\[([^\]]+)\]/);
501
+ return m && m[1].toLowerCase().includes(want);
502
+ });
503
+ };
504
+ const goals = tagged(state.current.goals).map(g => `- ${g.text}`).join('\n') || '(none)';
505
+ const nexts = tagged(state.current.next_actions).map(n => `- [ ] ${n.text}`).join('\n') || '(none)';
506
+ const questions = tagged(state.current.open_questions).map(q => `- ${q.text}`).join('\n') || '(none)';
507
+ const decisions = tagged(state.current.decisions.filter(d => visibleMemory(d, project ? { project } : {}))).slice(0, 5).map(d => {
495
508
  let line = `- ${d.text}`;
496
509
  if (d.why) line += ` — *${d.why}*`;
497
510
  return line;
@@ -5,6 +5,14 @@ import crypto from 'crypto';
5
5
  import { execFileSync } from 'child_process';
6
6
 
7
7
  const cache = new Map();
8
+
9
+ // Records written before the home directory got an explicit key carry the hash
10
+ // of the empty string. Treat that as an alias for home so they stay visible.
11
+ const LEGACY_HOME_ID = 'local:' + crypto.createHash('sha256').update('').digest('hex').slice(0, 32);
12
+ export function canonicalIdentity(id) {
13
+ return id === LEGACY_HOME_ID ? projectIdentity(os.homedir()) : id;
14
+ }
15
+
8
16
  export function projectIdentity(project = process.env.MEMOIR_PROJECT_ROOT || process.cwd()) {
9
17
  if (project === 'shared') return 'shared';
10
18
  if (/^(git|local):[a-f0-9]{32}$/.test(project)) return project;
@@ -14,7 +22,11 @@ export function projectIdentity(project = process.env.MEMOIR_PROJECT_ROOT || pro
14
22
  if (old && Date.now() - old.at < 60_000) return old.id;
15
23
  let home = os.homedir();
16
24
  try { home = fs.realpathSync(home); } catch {}
17
- let key = path.relative(home, absolute).replace(/\\/g, '/');
25
+ // The home directory itself is a legitimate working root for people who run
26
+ // their agent from ~. path.relative(home, home) is '', and hashing '' gives
27
+ // every such user the same degenerate id (sha256 of the empty string), so
28
+ // name it explicitly instead.
29
+ let key = path.relative(home, absolute).replace(/\\/g, '/') || '~';
18
30
  let kind = 'local';
19
31
  try {
20
32
  const remote = execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: absolute, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
@@ -44,9 +56,9 @@ export function memoryVisibility({ project = process.env.MEMOIR_PROJECT_ROOT ||
44
56
  if (item.claudeProjectKey !== currentKey && item.claudeProjectKey !== sharedKey) return false;
45
57
  }
46
58
  if (allProjects || !item.project || item.project === 'shared') return true;
47
- activeId ??= projectIdentity(project);
59
+ activeId ??= canonicalIdentity(projectIdentity(project));
48
60
  const key = String(item.project);
49
- if (!identities.has(key)) identities.set(key, projectIdentity(key));
61
+ if (!identities.has(key)) identities.set(key, canonicalIdentity(projectIdentity(key)));
50
62
  return identities.get(key) === activeId;
51
63
  };
52
64
  }