swarmdo 1.29.0 → 1.37.0

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.
Files changed (76) hide show
  1. package/.claude/helpers/auto-memory-hook.mjs +66 -33
  2. package/README.md +6 -3
  3. package/package.json +1 -1
  4. package/v3/@swarmdo/cli/.claude/helpers/auto-memory-hook.mjs +65 -32
  5. package/v3/@swarmdo/cli/dist/src/affected/affected.d.ts +6 -1
  6. package/v3/@swarmdo/cli/dist/src/affected/affected.js +7 -2
  7. package/v3/@swarmdo/cli/dist/src/apply/apply.js +18 -7
  8. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +4 -1
  9. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
  10. package/v3/@swarmdo/cli/dist/src/commands/comms.d.ts +16 -0
  11. package/v3/@swarmdo/cli/dist/src/commands/comms.js +280 -0
  12. package/v3/@swarmdo/cli/dist/src/commands/compact.js +6 -2
  13. package/v3/@swarmdo/cli/dist/src/commands/config.js +1 -0
  14. package/v3/@swarmdo/cli/dist/src/commands/hooks.js +121 -1
  15. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +8 -2
  16. package/v3/@swarmdo/cli/dist/src/commands/index.js +6 -0
  17. package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
  18. package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
  19. package/v3/@swarmdo/cli/dist/src/commands/usage.js +208 -3
  20. package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
  21. package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
  22. package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
  23. package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
  24. package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
  25. package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
  26. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +3 -1
  27. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +11 -4
  28. package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
  29. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +20 -1
  30. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
  31. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
  32. package/v3/@swarmdo/cli/dist/src/index.js +8 -0
  33. package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
  34. package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
  35. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
  36. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
  37. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
  38. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
  39. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
  40. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
  41. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
  42. package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
  43. package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
  44. package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
  45. package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
  46. package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
  47. package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
  48. package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
  49. package/v3/@swarmdo/cli/dist/src/redact/redact.js +7 -2
  50. package/v3/@swarmdo/cli/dist/src/release/release.js +1 -1
  51. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
  52. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
  53. package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
  54. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
  55. package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
  56. package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
  57. package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
  58. package/v3/@swarmdo/cli/dist/src/usage/limits.d.ts +77 -0
  59. package/v3/@swarmdo/cli/dist/src/usage/limits.js +134 -0
  60. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
  61. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
  62. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.d.ts +23 -0
  63. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +95 -0
  64. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +131 -0
  65. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +209 -0
  66. package/v3/@swarmdo/cli/dist/src/usage/spend-forecast.d.ts +28 -0
  67. package/v3/@swarmdo/cli/dist/src/usage/spend-forecast.js +33 -0
  68. package/v3/@swarmdo/cli/dist/src/usage/transcript-errors.d.ts +14 -0
  69. package/v3/@swarmdo/cli/dist/src/usage/transcript-errors.js +10 -0
  70. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
  71. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
  72. package/v3/@swarmdo/cli/dist/src/util/csv.d.ts +14 -0
  73. package/v3/@swarmdo/cli/dist/src/util/csv.js +20 -0
  74. package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
  75. package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
  76. package/v3/@swarmdo/cli/package.json +1 -1
@@ -36,6 +36,26 @@ const VITE_BUILTINS = new Set(['MODE', 'BASE_URL', 'PROD', 'DEV', 'SSR']);
36
36
  export function extractEnvRefs(source, file) {
37
37
  const out = [];
38
38
  const lines = source.split('\n');
39
+ // Map a byte offset in `source` to its 1-based line, for the whole-source
40
+ // destructure scan below (which can span lines). lineStart[i] = the offset
41
+ // where line i+1 begins; binary-search for the last start <= offset.
42
+ const lineStart = [0];
43
+ for (let k = 0; k < source.length; k++)
44
+ if (source[k] === '\n')
45
+ lineStart.push(k + 1);
46
+ const lineAt = (index) => {
47
+ let lo = 0, hi = lineStart.length - 1;
48
+ while (lo < hi) {
49
+ const mid = (lo + hi + 1) >> 1;
50
+ if (lineStart[mid] <= index)
51
+ lo = mid;
52
+ else
53
+ hi = mid - 1;
54
+ }
55
+ return lo + 1;
56
+ };
57
+ // Member-style reads (process.env.X, os.getenv('X'), …) are inherently
58
+ // single-line — scan line by line so each ref carries its own line number.
39
59
  for (let i = 0; i < lines.length; i++) {
40
60
  const line = lines[i];
41
61
  if (!line.includes('env') && !line.includes('getenv'))
@@ -50,21 +70,27 @@ export function extractEnvRefs(source, file) {
50
70
  out.push({ key, file, line: i + 1 });
51
71
  }
52
72
  }
53
- // Destructuring reads: `const { PORT, DB_URL } = process.env` (each property
54
- // key is an env-var reference). The `(?![.[])` guard keeps it to the bare
55
- // `process.env` object, not `= process.env.FOO`. Single-line, like the rest.
56
- DESTRUCTURE_RE.lastIndex = 0;
57
- let dm;
58
- while ((dm = DESTRUCTURE_RE.exec(line)) !== null) {
59
- for (const part of dm[1].split(',')) {
60
- // The env-var name is the property key left of a rename `:` or default `=`.
61
- const key = part.trim().split(/[:=]/)[0].trim();
62
- if (!/^[A-Za-z_$][\w$]*$/.test(key))
63
- continue; // skip `...rest`, empties
64
- if (VITE_BUILTINS.has(key))
65
- continue;
66
- out.push({ key, file, line: i + 1 });
73
+ }
74
+ // Destructuring reads: `const { PORT, DB_URL } = process.env`. Scanned over the
75
+ // WHOLE source, not per line — a Prettier-wrapped list spans lines:
76
+ // const {\n PORT,\n DB_URL,\n} = process.env
77
+ // The regex's `\s*`/`[^}]+` already match newlines, so running it per line was
78
+ // exactly what hid multi-line destructures (a false-clean `env --ci`). The
79
+ // `(?![.[])` guard keeps it to the bare object, not `= process.env.FOO`; each
80
+ // property key maps back to its own line via its offset.
81
+ DESTRUCTURE_RE.lastIndex = 0;
82
+ let dm;
83
+ while ((dm = DESTRUCTURE_RE.exec(source)) !== null) {
84
+ const bodyStart = dm.index + dm[0].indexOf(dm[1]);
85
+ let partOffset = 0;
86
+ for (const part of dm[1].split(',')) {
87
+ // The env-var name is the property key — left of a rename `:` or default `=`.
88
+ const key = part.trim().split(/[:=]/)[0].trim();
89
+ if (/^[A-Za-z_$][\w$]*$/.test(key) && !VITE_BUILTINS.has(key)) { // skip `...rest`, empties, builtins
90
+ const kOff = part.indexOf(key);
91
+ out.push({ key, file, line: lineAt(bodyStart + partOffset + (kOff < 0 ? 0 : kOff)) });
67
92
  }
93
+ partOffset += part.length + 1; // + the comma consumed by split
68
94
  }
69
95
  }
70
96
  return out;
@@ -23,6 +23,20 @@ export const RECIPES = [
23
23
  title: 'Desktop ping when Claude needs input',
24
24
  description: 'Fires a desktop notification when Claude is waiting for input or a permission decision (Notification hook).',
25
25
  },
26
+ {
27
+ name: 'memory-inject',
28
+ event: 'UserPromptSubmit',
29
+ command: 'swarmdo hooks memory-inject',
30
+ title: 'Inject relevant memories into each prompt',
31
+ description: 'On every prompt, semantically searches your stored memories (claude-memories/auto-memory/patterns) and injects the most relevant ones as context under a token budget (UserPromptSubmit hook). Powered by `swarmdo hooks memory-inject`. Opt-in: adds a small per-prompt search.',
32
+ },
33
+ {
34
+ name: 'comms-inbox',
35
+ event: 'UserPromptSubmit',
36
+ command: 'swarmdo comms inbox --hook',
37
+ title: 'Surface new cross-session messages each prompt',
38
+ description: 'On every prompt, injects any unread messages other sessions sent to this one (multiplayer swarms) as context, then marks them read — so mail surfaces without polling (UserPromptSubmit hook). Powered by `swarmdo comms inbox --hook`. Opt-in.',
39
+ },
26
40
  ];
27
41
  export function findRecipe(name) {
28
42
  const n = (name ?? '').trim().toLowerCase();
@@ -37,7 +51,12 @@ export function hasRecipe(settings, r) {
37
51
  const events = hooks?.[r.event];
38
52
  if (!Array.isArray(events))
39
53
  return false;
40
- return events.some((e) => e && typeof e === 'object' && Array.isArray(e.hooks) &&
54
+ // Match on matcher too: the same command under a DIFFERENT matcher is a
55
+ // distinct binding, so a matcher-scoped recipe isn't "already installed" just
56
+ // because its command appears elsewhere (matcher-less recipes compare
57
+ // undefined === undefined, unchanged).
58
+ return events.some((e) => e && typeof e === 'object' && e.matcher === r.matcher &&
59
+ Array.isArray(e.hooks) &&
41
60
  e.hooks.some((h) => h?.command === r.command));
42
61
  }
43
62
  /** Additively merge a recipe into a settings object. Pure: returns a new object,
@@ -64,6 +64,9 @@ export declare function resolveRenamePath(raw: string): string;
64
64
  export declare function parseGitLog(raw: string): Commit[];
65
65
  /** Aggregate commits into per-file hotspots, scored and ranked. Pure. */
66
66
  export declare function computeHotspots(commits: Commit[], now: number, opts?: HotspotOptions): FileHotspot[];
67
+ /** Export the ranking as CSV for spreadsheets / tech-debt review. Epoch dates
68
+ * render as ISO `YYYY-MM-DD` (UTC). Pure. */
69
+ export declare function hotspotsToCsv(spots: FileHotspot[]): string;
67
70
  /** Human-readable table. Pure. */
68
71
  export declare function formatHotspots(spots: FileHotspot[], now: number): string;
69
72
  //# sourceMappingURL=hotspots.d.ts.map
@@ -15,6 +15,7 @@
15
15
  * <SOH>%H<US>%an<US>%aI ← one header line per commit
16
16
  * <added>\t<deleted>\t<path> ← one numstat line per file ('-' for binary)
17
17
  */
18
+ import { toCsv } from '../util/csv.js';
18
19
  const SOH = '\x01'; // start-of-commit marker (git --format=format:%x01...)
19
20
  const US = '\x1f'; // field separator (%x1f)
20
21
  /**
@@ -134,6 +135,14 @@ export function computeHotspots(commits, now, opts = {}) {
134
135
  out = out.slice(0, opts.top);
135
136
  return out;
136
137
  }
138
+ /** Export the ranking as CSV for spreadsheets / tech-debt review. Epoch dates
139
+ * render as ISO `YYYY-MM-DD` (UTC). Pure. */
140
+ export function hotspotsToCsv(spots) {
141
+ const isoDay = (ms) => (ms > 0 ? new Date(ms).toISOString().slice(0, 10) : '');
142
+ const headers = ['path', 'risk', 'commits', 'churn', 'authors', 'firstTouched', 'lastTouched'];
143
+ const rows = spots.map((s) => [s.path, s.risk, s.commits, s.churn, s.authors, isoDay(s.firstTouched), isoDay(s.lastTouched)]);
144
+ return toCsv(headers, rows);
145
+ }
137
146
  /** Human-readable table. Pure. */
138
147
  export function formatHotspots(spots, now) {
139
148
  if (spots.length === 0)
@@ -20,6 +20,7 @@ import { commands, getCommandsByCategory, getCommand, getCommandAsync, getComman
20
20
  import { suggestCommand } from './suggest.js';
21
21
  import { runStartupUpdateCheck } from './update/index.js';
22
22
  import { migrateStorageDir } from './storage-migration.js';
23
+ import { guardStreamEpipe } from './util/stdout.js';
23
24
  // Read version from package.json at runtime
24
25
  function getPackageVersion() {
25
26
  try {
@@ -68,6 +69,13 @@ export class CLI {
68
69
  */
69
70
  async run(args = process.argv.slice(2)) {
70
71
  try {
72
+ // #78 — Arm the EPIPE guard on stdout/stderr before ANY write happens, so
73
+ // `swarmdo <cmd> | head` (reader closes the pipe early) terminates cleanly
74
+ // instead of crashing with an unhandled 'error' event + raw stack trace.
75
+ // Covers every write path (output.writeln, console, writeStdout), not just
76
+ // writeStdout callers. Idempotent per stream.
77
+ guardStreamEpipe(process.stdout);
78
+ guardStreamEpipe(process.stderr);
71
79
  // One-time storage migration: rename a legacy `.claude-flow/` data dir
72
80
  // to `.swarmdo/` so users upgrading across the clean-break rename keep
73
81
  // their agent registry, swarm state, task store, and sessions. Cheap
@@ -39,7 +39,10 @@ export function classifyLicense(pkg) {
39
39
  if (Array.isArray(arr) && arr.length) {
40
40
  const types = arr
41
41
  .map((e) => (typeof e === 'string' ? e : e?.type))
42
- .filter((t) => typeof t === 'string' && !!t.trim());
42
+ .filter((t) => typeof t === 'string' && !!t.trim())
43
+ // Normalize each entry like the singular `license`/`{type}` branches above,
44
+ // so npm sentinels (`SEE LICENSE IN …`, `UNLICENSED`) become UNKNOWN here too.
45
+ .map(normalizeNpmLicense);
43
46
  if (types.length)
44
47
  return types.join(' OR ');
45
48
  }
@@ -15,6 +15,7 @@ import { memoryTools } from './mcp-tools/memory-tools.js';
15
15
  import { configTools } from './mcp-tools/config-tools.js';
16
16
  import { hooksTools } from './mcp-tools/hooks-tools.js';
17
17
  import { taskTools } from './mcp-tools/task-tools.js';
18
+ import { commsTools } from './mcp-tools/comms-tools.js';
18
19
  import { sessionTools } from './mcp-tools/session-tools.js';
19
20
  import { hiveMindTools } from './mcp-tools/hive-mind-tools.js';
20
21
  import { workflowTools } from './mcp-tools/workflow-tools.js';
@@ -107,6 +108,7 @@ const TOOL_GROUPS = {
107
108
  config: () => configTools,
108
109
  hooks: () => hooksTools,
109
110
  task: () => taskTools,
111
+ comms: () => commsTools,
110
112
  session: () => sessionTools,
111
113
  'hive-mind': () => hiveMindTools,
112
114
  workflow: () => workflowTools,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Comms MCP Tools — cross-session agent mailbox (#44).
3
+ *
4
+ * Thin wrappers over the pure comms/mailbox.ts engine + comms/store.ts fs
5
+ * persistence, so an in-Claude session can message another session (or read its
6
+ * own inbox) without shelling out. Same `.swarmdo/comms/store.json` the
7
+ * `swarmdo comms` CLI uses.
8
+ */
9
+ import { type MCPTool } from './types.js';
10
+ export declare const commsTools: MCPTool[];
11
+ //# sourceMappingURL=comms-tools.d.ts.map
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Comms MCP Tools — cross-session agent mailbox (#44).
3
+ *
4
+ * Thin wrappers over the pure comms/mailbox.ts engine + comms/store.ts fs
5
+ * persistence, so an in-Claude session can message another session (or read its
6
+ * own inbox) without shelling out. Same `.swarmdo/comms/store.json` the
7
+ * `swarmdo comms` CLI uses.
8
+ */
9
+ import { getProjectCwd } from './types.js';
10
+ import { createMessage, addMessage, filterInbox, markRead, pruneMailbox } from '../comms/mailbox.js';
11
+ import { loadMailbox, saveMailbox, resolveSelf, newMessageId, RETENTION } from '../comms/store.js';
12
+ export const commsTools = [
13
+ {
14
+ name: 'comms_send',
15
+ description: 'Send a message to another Claude Code session working on the same repo (cross-session mailbox). Address "all" to broadcast to every session. Use for multi-session/"multiplayer" coordination; delivery is via a shared file, not the network.',
16
+ category: 'comms',
17
+ inputSchema: {
18
+ type: 'object',
19
+ properties: {
20
+ to: { type: 'string', description: 'Recipient session name, or "all" to broadcast' },
21
+ message: { type: 'string', description: 'Message body' },
22
+ subject: { type: 'string', description: 'Optional subject line' },
23
+ from: { type: 'string', description: 'Sender name (default: this session — $SWARMDO_SESSION or hostname)' },
24
+ },
25
+ required: ['to', 'message'],
26
+ },
27
+ handler: async (input) => {
28
+ const to = String(input.to ?? '').trim();
29
+ const body = String(input.message ?? '');
30
+ if (!to)
31
+ return { success: false, error: 'recipient (to) is required' };
32
+ if (!body.trim())
33
+ return { success: false, error: 'message is required' };
34
+ const cwd = getProjectCwd();
35
+ const from = resolveSelf(input.from);
36
+ const msg = createMessage({
37
+ id: newMessageId(),
38
+ from,
39
+ to,
40
+ subject: input.subject,
41
+ body,
42
+ createdAt: new Date().toISOString(),
43
+ });
44
+ let box = addMessage(loadMailbox(cwd), msg);
45
+ box = pruneMailbox(box, { ...RETENTION, nowMs: Date.now() });
46
+ saveMailbox(cwd, box);
47
+ return { success: true, id: msg.id, to: msg.to, from: msg.from, subject: msg.subject };
48
+ },
49
+ },
50
+ {
51
+ name: 'comms_inbox',
52
+ description: 'List messages other sessions have sent to this session (cross-session mailbox), newest first. Includes "all" broadcasts. Set markRead to acknowledge them.',
53
+ category: 'comms',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ to: { type: 'string', description: 'Whose inbox (default: this session)' },
58
+ unreadOnly: { type: 'boolean', description: 'Only unread messages' },
59
+ from: { type: 'string', description: 'Only messages from this sender' },
60
+ since: { type: 'string', description: 'Only messages after this ISO-8601 timestamp' },
61
+ markRead: { type: 'boolean', description: 'Mark the returned messages as read' },
62
+ },
63
+ },
64
+ handler: async (input) => {
65
+ const cwd = getProjectCwd();
66
+ const self = resolveSelf(input.to);
67
+ let box = loadMailbox(cwd);
68
+ const messages = filterInbox(box, {
69
+ to: self,
70
+ unreadOnly: input.unreadOnly === true,
71
+ from: input.from,
72
+ since: input.since,
73
+ });
74
+ let marked = 0;
75
+ if (input.markRead === true && messages.length > 0) {
76
+ const res = markRead(box, messages.map((m) => m.id));
77
+ if (res.marked > 0)
78
+ saveMailbox(cwd, res.box);
79
+ box = res.box;
80
+ marked = res.marked;
81
+ }
82
+ return {
83
+ success: true,
84
+ to: self,
85
+ count: messages.length,
86
+ unread: filterInbox(box, { to: self, unreadOnly: true }).length,
87
+ marked,
88
+ messages,
89
+ };
90
+ },
91
+ },
92
+ ];
93
+ //# sourceMappingURL=comms-tools.js.map
@@ -10,6 +10,7 @@ export { memoryTools } from './memory-tools.js';
10
10
  export { configTools } from './config-tools.js';
11
11
  export { hooksTools } from './hooks-tools.js';
12
12
  export { taskTools } from './task-tools.js';
13
+ export { commsTools } from './comms-tools.js';
13
14
  export { sessionTools } from './session-tools.js';
14
15
  export { hiveMindTools } from './hive-mind-tools.js';
15
16
  export { workflowTools } from './workflow-tools.js';
@@ -9,6 +9,7 @@ export { memoryTools } from './memory-tools.js';
9
9
  export { configTools } from './config-tools.js';
10
10
  export { hooksTools } from './hooks-tools.js';
11
11
  export { taskTools } from './task-tools.js';
12
+ export { commsTools } from './comms-tools.js';
12
13
  export { sessionTools } from './session-tools.js';
13
14
  export { hiveMindTools } from './hive-mind-tools.js';
14
15
  export { workflowTools } from './workflow-tools.js';
@@ -485,6 +485,10 @@ export const taskTools = [
485
485
  progress: 0,
486
486
  assignedTo: [...original.assignedTo],
487
487
  tags: [...original.tags, 'retry-of:' + taskId],
488
+ // Carry the dependency gate — a retried task must stay blocked until its
489
+ // prerequisites complete, exactly like the original. Dropping it let the
490
+ // dispatcher run the retry against unfinished/failed deps (a half-built state).
491
+ ...(original.dependsOn?.length ? { dependsOn: [...original.dependsOn] } : {}),
488
492
  createdAt: new Date().toISOString(),
489
493
  startedAt: null,
490
494
  completedAt: null,
@@ -54,6 +54,8 @@ export declare function bridgeSearchEntries(options: {
54
54
  limit?: number;
55
55
  threshold?: number;
56
56
  dbPath?: string;
57
+ /** Return full entry content instead of the 60-char preview (for memory injection). */
58
+ fullContent?: boolean;
57
59
  }): Promise<{
58
60
  success: boolean;
59
61
  results: {
@@ -714,7 +714,7 @@ export async function bridgeSearchEntries(options) {
714
714
  if (!ctx)
715
715
  return null;
716
716
  try {
717
- const { query: queryStr, namespace, limit = 10, threshold = 0.3 } = options;
717
+ const { query: queryStr, namespace, limit = 10, threshold = 0.3, fullContent = false } = options;
718
718
  const effectiveNamespace = namespace || 'all';
719
719
  const startTime = Date.now();
720
720
  // Generate query embedding
@@ -784,7 +784,9 @@ export async function bridgeSearchEntries(options) {
784
784
  results.push({
785
785
  id: String(row.id).substring(0, 12),
786
786
  key: row.key || String(row.id).substring(0, 15),
787
- content: (row.content || '').substring(0, 60) + ((row.content || '').length > 60 ? '...' : ''),
787
+ content: fullContent
788
+ ? (row.content || '')
789
+ : (row.content || '').substring(0, 60) + ((row.content || '').length > 60 ? '...' : ''),
788
790
  score,
789
791
  namespace: row.namespace || 'default',
790
792
  provenance,
@@ -0,0 +1,82 @@
1
+ /**
2
+ * memory-inject/select.ts — pure selection + formatting for prompt-time
3
+ * semantic memory injection (#43).
4
+ *
5
+ * A UserPromptSubmit hook embeds the current prompt, vector-searches AgentDB
6
+ * across the memory namespaces, and injects the most relevant stored memories
7
+ * into Claude's context under a strict token budget — recall at the moment of
8
+ * need instead of a manual `memory search` or a bulk session-start dump.
9
+ *
10
+ * This module is the DIFFERENTIATING logic and is deliberately pure (no I/O, no
11
+ * embeddings): given already-scored candidates it decides WHICH memories to
12
+ * inject (relevance threshold, cross-namespace dedup, token budget, top-k) and
13
+ * formats the context block. The command layer (`hooks memory-inject`) supplies
14
+ * the real embedding + vector search and emits the hook JSON.
15
+ */
16
+ /** One candidate memory returned by the vector search. */
17
+ export interface InjectionCandidate {
18
+ key: string;
19
+ namespace: string;
20
+ content: string;
21
+ /** Relevance (higher = more relevant), from the vector search. */
22
+ score: number;
23
+ provenance?: string;
24
+ }
25
+ export interface InjectionOptions {
26
+ /** Max tokens the whole injected block may occupy (estimate). Default 800. */
27
+ budgetTokens?: number;
28
+ /** Drop candidates scoring below this. Default 0.35. */
29
+ minRelevance?: number;
30
+ /** Never inject more than this many memories. Default 5. */
31
+ topK?: number;
32
+ /** Chars-per-token estimate for the budget. Default 4. */
33
+ charsPerToken?: number;
34
+ /** Trim each memory's content to this many chars before formatting. Default 400. */
35
+ maxItemChars?: number;
36
+ /** Heading for the injected block. */
37
+ header?: string;
38
+ }
39
+ export interface InjectionResult {
40
+ /** The formatted context block, or '' when nothing qualifies. */
41
+ block: string;
42
+ /** Candidates actually included (content already trimmed), in injection order. */
43
+ used: InjectionCandidate[];
44
+ /** Qualifying, unique candidates left out by the token budget or top-k cap. */
45
+ skipped: number;
46
+ /** Estimated tokens the rendered block occupies. */
47
+ tokensUsed: number;
48
+ }
49
+ /** Rough token estimate: ceil(chars / charsPerToken). */
50
+ export declare function estimateTokens(text: string, charsPerToken?: number): number;
51
+ /** Collapse whitespace and cap length at a word boundary, adding an ellipsis. */
52
+ export declare function trimContent(content: string, maxChars: number): string;
53
+ /**
54
+ * Select and format the memories to inject.
55
+ *
56
+ * Pipeline: drop empty/below-threshold → sort by score desc (shorter content
57
+ * wins ties so the budget packs more) → dedup by normalized content → greedily
58
+ * fill the token budget up to top-k, trimming each item. Returns block: '' when
59
+ * nothing qualifies (caller injects nothing — no context noise).
60
+ */
61
+ export declare function selectInjectionMemories(candidates: InjectionCandidate[], options?: InjectionOptions): InjectionResult;
62
+ /**
63
+ * Extract the user's prompt from a Claude Code UserPromptSubmit hook payload.
64
+ * Claude Code pipes JSON like `{"prompt":"…","session_id":"…"}` on stdin. Falls
65
+ * back to `.userPrompt`/`.message`, then to the raw text when it isn't JSON
66
+ * (a caller passing a plain string). Returns '' when nothing usable is found.
67
+ */
68
+ export declare function extractPromptFromPayload(raw: string): string;
69
+ /** A vector-search row as returned by bridgeSearchEntries. */
70
+ export interface SearchRow {
71
+ key: string;
72
+ content: string;
73
+ score: number;
74
+ namespace: string;
75
+ provenance?: string;
76
+ }
77
+ /**
78
+ * Map raw search rows to injection candidates, dropping empty content and
79
+ * (when `allowed` is given) rows outside the requested namespace set.
80
+ */
81
+ export declare function mapSearchResultsToCandidates(results: SearchRow[], allowed?: string[] | Set<string>): InjectionCandidate[];
82
+ //# sourceMappingURL=select.d.ts.map
@@ -0,0 +1,164 @@
1
+ /**
2
+ * memory-inject/select.ts — pure selection + formatting for prompt-time
3
+ * semantic memory injection (#43).
4
+ *
5
+ * A UserPromptSubmit hook embeds the current prompt, vector-searches AgentDB
6
+ * across the memory namespaces, and injects the most relevant stored memories
7
+ * into Claude's context under a strict token budget — recall at the moment of
8
+ * need instead of a manual `memory search` or a bulk session-start dump.
9
+ *
10
+ * This module is the DIFFERENTIATING logic and is deliberately pure (no I/O, no
11
+ * embeddings): given already-scored candidates it decides WHICH memories to
12
+ * inject (relevance threshold, cross-namespace dedup, token budget, top-k) and
13
+ * formats the context block. The command layer (`hooks memory-inject`) supplies
14
+ * the real embedding + vector search and emits the hook JSON.
15
+ */
16
+ const DEFAULTS = {
17
+ budgetTokens: 800,
18
+ minRelevance: 0.35,
19
+ topK: 5,
20
+ charsPerToken: 4,
21
+ maxItemChars: 400,
22
+ header: '## 🧠 Relevant memories (swarmdo)',
23
+ };
24
+ /**
25
+ * Intro line placed under the header. States the provenance + a verify caveat —
26
+ * injected memories reflect what was true WHEN WRITTEN, so file/flag names may
27
+ * be stale (matches the auto-memory guidance).
28
+ */
29
+ const INTRO = 'Retrieved from stored memory by semantic search — may be relevant to this request. ' +
30
+ 'They reflect what was true when written; verify file/flag/function names before relying on them.';
31
+ /** Rough token estimate: ceil(chars / charsPerToken). */
32
+ export function estimateTokens(text, charsPerToken = DEFAULTS.charsPerToken) {
33
+ const cpt = charsPerToken > 0 ? charsPerToken : DEFAULTS.charsPerToken;
34
+ return Math.ceil(text.length / cpt);
35
+ }
36
+ /** Collapse whitespace and cap length at a word boundary, adding an ellipsis. */
37
+ export function trimContent(content, maxChars) {
38
+ const collapsed = content.replace(/\s+/g, ' ').trim();
39
+ if (maxChars <= 0 || collapsed.length <= maxChars)
40
+ return collapsed;
41
+ const cut = collapsed.slice(0, maxChars);
42
+ const lastSpace = cut.lastIndexOf(' ');
43
+ const body = lastSpace > maxChars * 0.6 ? cut.slice(0, lastSpace) : cut;
44
+ return body.trimEnd() + '…';
45
+ }
46
+ /** Normalized dedup key — the same memory bridged into >1 namespace collapses. */
47
+ function dedupKey(c) {
48
+ return c.content.replace(/\s+/g, ' ').trim().toLowerCase();
49
+ }
50
+ /** Format one memory as a source-attributed bullet. Score shown as a percent. */
51
+ function formatItem(c) {
52
+ const pct = Math.round(Math.max(0, Math.min(1, c.score)) * 100);
53
+ return `- **[${c.namespace}/${c.key}]** (${pct}%) ${c.content}`;
54
+ }
55
+ function renderBlock(used, header) {
56
+ const lines = [header, INTRO, '', ...used.map(formatItem)];
57
+ return lines.join('\n');
58
+ }
59
+ /**
60
+ * Select and format the memories to inject.
61
+ *
62
+ * Pipeline: drop empty/below-threshold → sort by score desc (shorter content
63
+ * wins ties so the budget packs more) → dedup by normalized content → greedily
64
+ * fill the token budget up to top-k, trimming each item. Returns block: '' when
65
+ * nothing qualifies (caller injects nothing — no context noise).
66
+ */
67
+ export function selectInjectionMemories(candidates, options = {}) {
68
+ const opts = {
69
+ budgetTokens: options.budgetTokens ?? DEFAULTS.budgetTokens,
70
+ minRelevance: options.minRelevance ?? DEFAULTS.minRelevance,
71
+ topK: options.topK ?? DEFAULTS.topK,
72
+ charsPerToken: options.charsPerToken ?? DEFAULTS.charsPerToken,
73
+ maxItemChars: options.maxItemChars ?? DEFAULTS.maxItemChars,
74
+ header: options.header ?? DEFAULTS.header,
75
+ };
76
+ const budget = Math.max(0, opts.budgetTokens);
77
+ const topK = Math.max(0, Math.floor(opts.topK));
78
+ const qualifying = (candidates || []).filter((c) => c &&
79
+ typeof c.content === 'string' &&
80
+ c.content.trim().length > 0 &&
81
+ typeof c.score === 'number' &&
82
+ c.score >= opts.minRelevance);
83
+ const sorted = [...qualifying].sort((a, b) => b.score - a.score || a.content.length - b.content.length || a.key.localeCompare(b.key));
84
+ const seen = new Set();
85
+ const unique = [];
86
+ for (const c of sorted) {
87
+ const k = dedupKey(c);
88
+ if (seen.has(k))
89
+ continue;
90
+ seen.add(k);
91
+ unique.push(c);
92
+ }
93
+ // Fixed overhead of the header + intro line; every bullet must fit alongside it.
94
+ const overhead = estimateTokens(`${opts.header}\n${INTRO}\n`, opts.charsPerToken);
95
+ const used = [];
96
+ let lineTokens = 0;
97
+ let skipped = 0;
98
+ for (const c of unique) {
99
+ if (used.length >= topK) {
100
+ skipped++;
101
+ continue;
102
+ }
103
+ const trimmed = { ...c, content: trimContent(c.content, opts.maxItemChars) };
104
+ const cost = estimateTokens('\n' + formatItem(trimmed), opts.charsPerToken);
105
+ if (overhead + lineTokens + cost > budget) {
106
+ skipped++;
107
+ continue;
108
+ }
109
+ used.push(trimmed);
110
+ lineTokens += cost;
111
+ }
112
+ if (used.length === 0) {
113
+ return { block: '', used: [], skipped, tokensUsed: 0 };
114
+ }
115
+ const block = renderBlock(used, opts.header);
116
+ return { block, used, skipped, tokensUsed: estimateTokens(block, opts.charsPerToken) };
117
+ }
118
+ /**
119
+ * Extract the user's prompt from a Claude Code UserPromptSubmit hook payload.
120
+ * Claude Code pipes JSON like `{"prompt":"…","session_id":"…"}` on stdin. Falls
121
+ * back to `.userPrompt`/`.message`, then to the raw text when it isn't JSON
122
+ * (a caller passing a plain string). Returns '' when nothing usable is found.
123
+ */
124
+ export function extractPromptFromPayload(raw) {
125
+ const text = (raw || '').trim();
126
+ if (!text)
127
+ return '';
128
+ try {
129
+ const obj = JSON.parse(text);
130
+ if (obj && typeof obj === 'object') {
131
+ const p = obj.prompt ?? obj.userPrompt ?? obj.message ?? obj.text;
132
+ return typeof p === 'string' ? p.trim() : '';
133
+ }
134
+ // JSON that parsed to a bare string/number → use its string form.
135
+ return typeof obj === 'string' ? obj.trim() : '';
136
+ }
137
+ catch {
138
+ // Not JSON — treat the raw text as the prompt.
139
+ return text;
140
+ }
141
+ }
142
+ /**
143
+ * Map raw search rows to injection candidates, dropping empty content and
144
+ * (when `allowed` is given) rows outside the requested namespace set.
145
+ */
146
+ export function mapSearchResultsToCandidates(results, allowed) {
147
+ const allow = allowed ? (allowed instanceof Set ? allowed : new Set(allowed)) : null;
148
+ const out = [];
149
+ for (const r of results || []) {
150
+ if (!r || typeof r.content !== 'string' || r.content.trim().length === 0)
151
+ continue;
152
+ if (allow && !allow.has(r.namespace))
153
+ continue;
154
+ out.push({
155
+ key: r.key,
156
+ namespace: r.namespace,
157
+ content: r.content,
158
+ score: typeof r.score === 'number' ? r.score : 0,
159
+ provenance: r.provenance,
160
+ });
161
+ }
162
+ return out;
163
+ }
164
+ //# sourceMappingURL=select.js.map
@@ -27,12 +27,22 @@ export function unfenceBody(body) {
27
27
  const m = /^```json\n([\s\S]*)\n```$/.exec(t);
28
28
  if (!m)
29
29
  return body;
30
+ let parsed;
30
31
  try {
31
- return JSON.stringify(JSON.parse(m[1]));
32
+ parsed = JSON.parse(m[1]);
32
33
  }
33
34
  catch {
34
35
  return body;
35
36
  }
37
+ // Only unfence a block that renderBody itself produced (it always pretty-prints
38
+ // with 2-space indent). A raw stored value that merely HAPPENS to already be a
39
+ // ```json fence — renderBody left it untouched because it isn't a bare
40
+ // object/array — must round-trip byte-for-byte; minifying it would silently
41
+ // rewrite the user's data on every export→import even with zero edits.
42
+ const reFenced = '```json\n' + JSON.stringify(parsed, null, 2) + '\n```';
43
+ if (reFenced !== t)
44
+ return body;
45
+ return JSON.stringify(parsed);
36
46
  }
37
47
  /** Parse one note. Returns null unless it carries our frontmatter stamp. */
38
48
  export function parseNote(content) {
@@ -193,9 +193,13 @@ export function makeIgnoreMatcher(patterns) {
193
193
  const dirOnly = body.endsWith('/');
194
194
  if (dirOnly)
195
195
  body = body.slice(0, -1);
196
- const anchored = body.startsWith('/');
197
- if (anchored)
196
+ const leadingSlash = body.startsWith('/');
197
+ if (leadingSlash)
198
198
  body = body.slice(1);
199
+ // gitignore(5): a slash ANYWHERE (not just leading) anchors the pattern to
200
+ // the root — `src/fixtures` matches `src/fixtures` but NOT `a/src/fixtures`.
201
+ // Only a slash-free pattern (`dir`, `*.ext`) matches at any depth.
202
+ const anchored = leadingSlash || body.includes('/');
199
203
  const re = new RegExp('^' + globToRegExp(body) + (dirOnly ? '(/|$)' : '($|/)'));
200
204
  return { negate, anchored, re };
201
205
  });