swarmdo 1.30.0 → 1.45.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.
- package/README.md +11 -6
- package/package.json +1 -1
- package/v3/@swarmdo/cli/dist/src/affected/affected.d.ts +6 -1
- package/v3/@swarmdo/cli/dist/src/affected/affected.js +7 -2
- package/v3/@swarmdo/cli/dist/src/apply/apply.js +24 -8
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.d.ts +21 -0
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +47 -1
- package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
- package/v3/@swarmdo/cli/dist/src/commands/changelog.js +15 -4
- package/v3/@swarmdo/cli/dist/src/commands/comms.d.ts +16 -0
- package/v3/@swarmdo/cli/dist/src/commands/comms.js +280 -0
- package/v3/@swarmdo/cli/dist/src/commands/compact.js +6 -2
- package/v3/@swarmdo/cli/dist/src/commands/config.js +7 -1
- package/v3/@swarmdo/cli/dist/src/commands/coupling.d.ts +17 -0
- package/v3/@swarmdo/cli/dist/src/commands/coupling.js +85 -0
- package/v3/@swarmdo/cli/dist/src/commands/hooks.js +176 -1
- package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +10 -3
- package/v3/@swarmdo/cli/dist/src/commands/index.js +12 -3
- package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
- package/v3/@swarmdo/cli/dist/src/commands/redact.js +11 -0
- package/v3/@swarmdo/cli/dist/src/commands/release.js +27 -5
- package/v3/@swarmdo/cli/dist/src/commands/task.js +57 -2
- package/v3/@swarmdo/cli/dist/src/commands/usage.js +34 -1
- package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
- package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
- package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
- package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
- package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
- package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
- package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.d.ts +30 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.js +87 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +6 -1
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +14 -4
- package/v3/@swarmdo/cli/dist/src/coupling/coupling.d.ts +56 -0
- package/v3/@swarmdo/cli/dist/src/coupling/coupling.js +86 -0
- package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.d.ts +46 -0
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.js +120 -0
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +28 -1
- package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
- package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
- package/v3/@swarmdo/cli/dist/src/index.js +8 -0
- package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
- package/v3/@swarmdo/cli/dist/src/mcp-client.js +4 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.js +56 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +2 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +2 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.d.ts +35 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.js +87 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
- package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
- package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
- package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
- package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
- package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
- package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
- package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
- package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
- package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
- package/v3/@swarmdo/cli/dist/src/redact/redact.js +22 -5
- package/v3/@swarmdo/cli/dist/src/redact/sarif.d.ts +28 -0
- package/v3/@swarmdo/cli/dist/src/redact/sarif.js +53 -0
- package/v3/@swarmdo/cli/dist/src/release/release.d.ts +19 -0
- package/v3/@swarmdo/cli/dist/src/release/release.js +29 -1
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
- package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
- package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
- package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
- package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
- package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
- package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
- package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +1 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +35 -1
- package/v3/@swarmdo/cli/dist/src/usage/reflect.js +58 -4
- package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
- package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
- package/v3/@swarmdo/cli/dist/src/util/since.d.ts +13 -0
- package/v3/@swarmdo/cli/dist/src/util/since.js +21 -0
- package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
- package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
- package/v3/@swarmdo/cli/package.json +1 -1
|
@@ -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
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coupling MCP Tool
|
|
3
|
+
*
|
|
4
|
+
* Let an agent ask "which files change together?" and get ranked JSON — the
|
|
5
|
+
* EMPIRICAL complement to `affected`'s static import graph. Files that keep
|
|
6
|
+
* landing in the same commit are coupled in practice even when no import edge
|
|
7
|
+
* connects them (a JSON schema and its TS type, a serializer/deserializer split
|
|
8
|
+
* across modules, a doc that must track an API). Surfacing that lets an agent
|
|
9
|
+
* catch the co-edit it would otherwise forget. Shares the pure engine in
|
|
10
|
+
* ../coupling/coupling.ts with the CLI; captures git history via subprocess.
|
|
11
|
+
*/
|
|
12
|
+
import type { MCPTool } from './types.js';
|
|
13
|
+
export declare const couplingTools: MCPTool[];
|
|
14
|
+
//# sourceMappingURL=coupling-tools.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coupling MCP Tool
|
|
3
|
+
*
|
|
4
|
+
* Let an agent ask "which files change together?" and get ranked JSON — the
|
|
5
|
+
* EMPIRICAL complement to `affected`'s static import graph. Files that keep
|
|
6
|
+
* landing in the same commit are coupled in practice even when no import edge
|
|
7
|
+
* connects them (a JSON schema and its TS type, a serializer/deserializer split
|
|
8
|
+
* across modules, a doc that must track an API). Surfacing that lets an agent
|
|
9
|
+
* catch the co-edit it would otherwise forget. Shares the pure engine in
|
|
10
|
+
* ../coupling/coupling.ts with the CLI; captures git history via subprocess.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { parseGitLog } from '../hotspots/hotspots.js';
|
|
14
|
+
import { computeCoupling } from '../coupling/coupling.js';
|
|
15
|
+
import { normalizeSince } from '../util/since.js';
|
|
16
|
+
const couplingTool = {
|
|
17
|
+
name: 'coupling',
|
|
18
|
+
description: 'Rank file pairs that change together in git history (temporal/co-change coupling) — the empirical complement to `affected`. Returns ranked JSON so an agent editing one file can see what else historically moves with it and avoid a forgotten co-edit. Pass `file` for a "what changes with X?" query. Runs in a git repository.',
|
|
19
|
+
category: 'coupling',
|
|
20
|
+
tags: ['git', 'co-change', 'coupling', 'impact', 'refactor'],
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
path: { type: 'string', description: 'repo path to run in (default cwd)' },
|
|
25
|
+
since: { type: 'string', description: 'history window, e.g. 90d or "3 months ago" (default 1 year)' },
|
|
26
|
+
top: { type: 'number', description: 'keep only the top N pairs (default 30; 0 = all)' },
|
|
27
|
+
minShared: { type: 'number', description: 'drop pairs sharing fewer than N commits (default 2)' },
|
|
28
|
+
maxFiles: { type: 'number', description: 'skip commits touching more than N files (default 30; 0 = no cap)' },
|
|
29
|
+
file: { type: 'string', description: 'show only pairs involving this exact path ("what changes with X?")' },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
handler: async (params) => {
|
|
33
|
+
const cwd = typeof params.path === 'string' ? params.path : process.cwd();
|
|
34
|
+
const since = typeof params.since === 'string' ? params.since : '1 year ago';
|
|
35
|
+
const top = typeof params.top === 'number' ? params.top : 30;
|
|
36
|
+
const minShared = typeof params.minShared === 'number' ? params.minShared : 2;
|
|
37
|
+
const maxFiles = typeof params.maxFiles === 'number' ? params.maxFiles : 30;
|
|
38
|
+
const focus = typeof params.file === 'string' && params.file ? params.file : undefined;
|
|
39
|
+
// Capture the FULL history (no pathspec filter even when focused) — filtering
|
|
40
|
+
// the log to `focus` would strip the co-changed files out of each commit's
|
|
41
|
+
// numstat. We filter PAIRS in the engine (opts.focus) instead. Same
|
|
42
|
+
// `--numstat` dump `hotspots` parses.
|
|
43
|
+
const args = ['log', '--no-merges', '--numstat', `--since=${normalizeSince(since)}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
|
|
44
|
+
let raw;
|
|
45
|
+
try {
|
|
46
|
+
raw = execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 128 * 1024 * 1024 });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { error: true, message: 'git log failed — not a git repository?' };
|
|
50
|
+
}
|
|
51
|
+
const coupling = computeCoupling(parseGitLog(raw), { minShared, maxFiles, top: top > 0 ? top : undefined, focus });
|
|
52
|
+
return { since, minShared, count: coupling.length, coupling };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
export const couplingTools = [couplingTool];
|
|
56
|
+
//# sourceMappingURL=coupling-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';
|
|
@@ -21,6 +22,7 @@ export { envTools } from './env-tools.js';
|
|
|
21
22
|
export { licenseTools } from './license-tools.js';
|
|
22
23
|
export { applyTools } from './apply-tools.js';
|
|
23
24
|
export { hotspotsTools } from './hotspots-tools.js';
|
|
25
|
+
export { couplingTools } from './coupling-tools.js';
|
|
24
26
|
export { affectedTools } from './affected-tools.js';
|
|
25
27
|
export { cyclesTools } from './cycles-tools.js';
|
|
26
28
|
export { testreportTools } from './testreport-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';
|
|
@@ -20,6 +21,7 @@ export { envTools } from './env-tools.js';
|
|
|
20
21
|
export { licenseTools } from './license-tools.js';
|
|
21
22
|
export { applyTools } from './apply-tools.js';
|
|
22
23
|
export { hotspotsTools } from './hotspots-tools.js';
|
|
24
|
+
export { couplingTools } from './coupling-tools.js';
|
|
23
25
|
export { affectedTools } from './affected-tools.js';
|
|
24
26
|
export { cyclesTools } from './cycles-tools.js';
|
|
25
27
|
export { testreportTools } from './testreport-tools.js';
|
|
@@ -27,6 +27,7 @@ const LEAN_GROUPS = [
|
|
|
27
27
|
'env', // env_check — env-var drift (missing/unused/undocumented)
|
|
28
28
|
'apply', // apply_patch — fuzzy unified-diff applier for agent edits
|
|
29
29
|
'hotspots', // hotspots — git-history change-risk ranking ("where's the debt?")
|
|
30
|
+
'coupling', // coupling — files that change together (temporal co-change), complements affected
|
|
30
31
|
'affected', // affected — impacted files/tests from a change via the import graph
|
|
31
32
|
'cycles', // cycles — circular-import detection via the import graph
|
|
32
33
|
'testreport', // testreport — JUnit/TAP → structured failure digest (pairs w/ repair)
|
|
@@ -42,6 +42,41 @@ export declare function readyTasks(store: TaskStore): TaskRecord[];
|
|
|
42
42
|
export declare function blockedTasks(store: TaskStore): BlockedInfo[];
|
|
43
43
|
/** Tasks that became ready exactly because `completedTaskId` completed. */
|
|
44
44
|
export declare function unblockedBy(store: TaskStore, completedTaskId: string): TaskRecord[];
|
|
45
|
+
export interface DeadBlock {
|
|
46
|
+
task: TaskRecord;
|
|
47
|
+
/**
|
|
48
|
+
* The terminal-dead dependency ids in this task's dependency closure — the
|
|
49
|
+
* failed, cancelled, or missing tasks that make it permanently unreachable
|
|
50
|
+
* until a human retries/cancels/creates them. Deterministic (sorted).
|
|
51
|
+
*/
|
|
52
|
+
rootCause: string[];
|
|
53
|
+
}
|
|
54
|
+
export interface TaskDagHealth {
|
|
55
|
+
/** pending tasks whose unmet deps can STILL complete — a transient wait */
|
|
56
|
+
liveBlocked: TaskRecord[];
|
|
57
|
+
/** pending tasks that can NEVER become ready without human intervention */
|
|
58
|
+
deadBlocked: DeadBlock[];
|
|
59
|
+
/**
|
|
60
|
+
* true when pending work exists but nothing is ready AND nothing is
|
|
61
|
+
* in_progress — the DAG cannot advance on its own. A coordinator looping on
|
|
62
|
+
* `task dispatch` would otherwise spin forever doing nothing.
|
|
63
|
+
*/
|
|
64
|
+
deadlocked: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Classify every blocked task as live- vs dead-blocked and report a global
|
|
68
|
+
* deadlock verdict. Pure.
|
|
69
|
+
*
|
|
70
|
+
* This is Apache Airflow's `upstream_failed` state + DAG-deadlock detection,
|
|
71
|
+
* ported onto the beads-style task DAG (see the module header): a dependency
|
|
72
|
+
* that failed / was cancelled / is missing NEVER satisfies (only 'completed'
|
|
73
|
+
* does), so every task transitively downstream is permanently stuck until a
|
|
74
|
+
* human intervenes. `blockedTasks()` reports the wait but can't tell a
|
|
75
|
+
* transient wait from a permanent one — the dispatcher then skips a
|
|
76
|
+
* failed-dep block every pass, indistinguishable from a normal wait, and the
|
|
77
|
+
* DAG quietly wedges. This turns that silent stall into a named diagnostic.
|
|
78
|
+
*/
|
|
79
|
+
export declare function taskDagHealth(store: TaskStore): TaskDagHealth;
|
|
45
80
|
/**
|
|
46
81
|
* Flat dependency view: one line per task, dependencies inline with their
|
|
47
82
|
* states. A DAG has no single tree; a flat annotated list stays honest for
|
|
@@ -94,6 +94,93 @@ export function blockedTasks(store) {
|
|
|
94
94
|
export function unblockedBy(store, completedTaskId) {
|
|
95
95
|
return Object.values(store.tasks).filter((t) => t.status === 'pending' && (t.dependsOn ?? []).includes(completedTaskId) && isReady(store, t));
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Classify every blocked task as live- vs dead-blocked and report a global
|
|
99
|
+
* deadlock verdict. Pure.
|
|
100
|
+
*
|
|
101
|
+
* This is Apache Airflow's `upstream_failed` state + DAG-deadlock detection,
|
|
102
|
+
* ported onto the beads-style task DAG (see the module header): a dependency
|
|
103
|
+
* that failed / was cancelled / is missing NEVER satisfies (only 'completed'
|
|
104
|
+
* does), so every task transitively downstream is permanently stuck until a
|
|
105
|
+
* human intervenes. `blockedTasks()` reports the wait but can't tell a
|
|
106
|
+
* transient wait from a permanent one — the dispatcher then skips a
|
|
107
|
+
* failed-dep block every pass, indistinguishable from a normal wait, and the
|
|
108
|
+
* DAG quietly wedges. This turns that silent stall into a named diagnostic.
|
|
109
|
+
*/
|
|
110
|
+
export function taskDagHealth(store) {
|
|
111
|
+
// canComplete(id): can this task ever reach status 'completed'?
|
|
112
|
+
// completed → yes; failed/cancelled/missing → no; pending/in_progress →
|
|
113
|
+
// only if EVERY dependency can complete. Memoized; a cycle (the store is
|
|
114
|
+
// acyclic by invariant, but guard anyway) resolves to false.
|
|
115
|
+
const memo = new Map();
|
|
116
|
+
const visiting = new Set();
|
|
117
|
+
const canComplete = (id) => {
|
|
118
|
+
const cached = memo.get(id);
|
|
119
|
+
if (cached !== undefined)
|
|
120
|
+
return cached;
|
|
121
|
+
const task = store.tasks[id];
|
|
122
|
+
if (!task)
|
|
123
|
+
return false; // missing id — blocks forever
|
|
124
|
+
if (task.status === 'completed')
|
|
125
|
+
return true;
|
|
126
|
+
if (task.status === 'failed' || task.status === 'cancelled')
|
|
127
|
+
return false;
|
|
128
|
+
if (visiting.has(id))
|
|
129
|
+
return false; // cycle guard
|
|
130
|
+
visiting.add(id);
|
|
131
|
+
const ok = (task.dependsOn ?? []).every((d) => canComplete(d));
|
|
132
|
+
visiting.delete(id);
|
|
133
|
+
memo.set(id, ok);
|
|
134
|
+
return ok;
|
|
135
|
+
};
|
|
136
|
+
// The terminal-dead ids (failed/cancelled/missing) reachable from a task,
|
|
137
|
+
// descending THROUGH still-pending intermediates to name the real culprit.
|
|
138
|
+
const deadRoots = (task) => {
|
|
139
|
+
const found = new Set();
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
const stack = [...(task.dependsOn ?? [])];
|
|
142
|
+
while (stack.length) {
|
|
143
|
+
const id = stack.pop();
|
|
144
|
+
if (seen.has(id))
|
|
145
|
+
continue;
|
|
146
|
+
seen.add(id);
|
|
147
|
+
const dep = store.tasks[id];
|
|
148
|
+
if (!dep) {
|
|
149
|
+
found.add(id);
|
|
150
|
+
continue;
|
|
151
|
+
} // missing
|
|
152
|
+
if (dep.status === 'failed' || dep.status === 'cancelled') {
|
|
153
|
+
found.add(id);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (dep.status === 'completed')
|
|
157
|
+
continue; // satisfied — not a culprit
|
|
158
|
+
for (const d of dep.dependsOn ?? [])
|
|
159
|
+
stack.push(d); // pending/in_progress → descend
|
|
160
|
+
}
|
|
161
|
+
return [...found].sort();
|
|
162
|
+
};
|
|
163
|
+
const liveBlocked = [];
|
|
164
|
+
const deadBlocked = [];
|
|
165
|
+
for (const task of Object.values(store.tasks)) {
|
|
166
|
+
if (task.status !== 'pending')
|
|
167
|
+
continue;
|
|
168
|
+
const deps = task.dependsOn ?? [];
|
|
169
|
+
if (deps.length === 0)
|
|
170
|
+
continue; // ready (dep-free), not blocked
|
|
171
|
+
if (deps.every((id) => satisfies(store.tasks[id])))
|
|
172
|
+
continue; // ready, not blocked
|
|
173
|
+
if (deps.every((id) => canComplete(id)))
|
|
174
|
+
liveBlocked.push(task);
|
|
175
|
+
else
|
|
176
|
+
deadBlocked.push({ task, rootCause: deadRoots(task) });
|
|
177
|
+
}
|
|
178
|
+
const all = Object.values(store.tasks);
|
|
179
|
+
const anyPending = all.some((t) => t.status === 'pending');
|
|
180
|
+
const anyInProgress = all.some((t) => t.status === 'in_progress');
|
|
181
|
+
const deadlocked = anyPending && readyTasks(store).length === 0 && !anyInProgress;
|
|
182
|
+
return { liveBlocked, deadBlocked, deadlocked };
|
|
183
|
+
}
|
|
97
184
|
const STATUS_MARK = {
|
|
98
185
|
completed: '✔',
|
|
99
186
|
in_progress: '▶',
|
|
@@ -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:
|
|
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
|
-
|
|
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) {
|