claude-code-session-manager 0.57.1 → 0.59.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 +2 -2
- package/dist/assets/{TiptapBody-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
- package/dist/assets/index-BPnfPdLW.js +3222 -0
- package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -2
- package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
- package/scripts/lib/watchdogHelpers.cjs +0 -434
- package/scripts/mint-epic.cjs +6 -6
- package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
- package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
- package/src/main/__tests__/epicMint.test.cjs +165 -207
- package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
- package/src/main/__tests__/prdMigration.test.cjs +160 -0
- package/src/main/__tests__/projectPages.test.cjs +151 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
- package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
- package/src/main/__tests__/rcaReport.test.cjs +188 -0
- package/src/main/__tests__/runVerify.test.cjs +109 -6
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
- package/src/main/chatRunner.cjs +10 -1
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +3 -5
- package/src/main/ipcSchemas.cjs +72 -4
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/agentModelResolve.cjs +97 -0
- package/src/main/lib/auditLog.cjs +2 -2
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicContextDigest.cjs +78 -0
- package/src/main/lib/epicMint.cjs +86 -164
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +95 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
- package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
- package/src/main/lib/schedulerBatch.cjs +70 -111
- package/src/main/lib/schedulerConfig.cjs +0 -1
- package/src/main/otel.cjs +3 -1
- package/src/main/projectPages.cjs +60 -14
- package/src/main/promptSessionEvents.cjs +16 -1
- package/src/main/runVerify.cjs +17 -2
- package/src/main/scheduler.cjs +302 -68
- package/src/main/templates/project-pages-default-home.html +123 -0
- package/src/main/transcripts.cjs +191 -32
- package/src/main/webRemote.cjs +8 -7
- package/src/preload/api.d.ts +100 -57
- package/src/preload/index.cjs +9 -7
- package/dist/assets/index-DCa3QvAi.js +0 -3227
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
- package/scripts/propose-epic.cjs +0 -56
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
- package/src/main/repoAnalyzer.cjs +0 -346
- package/src/main/search.cjs +0 -332
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* agentModelResolve.cjs — resolves the `--model` an Epic's session should
|
|
5
|
+
* launch a headless `claude -p` (or Terminal `claude`) process with: its
|
|
6
|
+
* agentType persona's own `model` field (Agent Library) when the Epic has
|
|
7
|
+
* one set and it isn't 'inherit', else `fallbackModel`.
|
|
8
|
+
*
|
|
9
|
+
* Extracted from PRD agent-model-default-terminal (which wired this for the
|
|
10
|
+
* Terminal-view launch in EpicTerminalPane.tsx) so chatRunner.cjs's headless
|
|
11
|
+
* Chat-view launch resolves the SAME persona-derived model — Chat and
|
|
12
|
+
* Terminal are two VIEWS over one Epic session (CLAUDE.md) and must agree on
|
|
13
|
+
* which model that session launches with.
|
|
14
|
+
*
|
|
15
|
+
* Plain Node module (sync fs) — no Electron deps, mirrors epicMint.cjs's
|
|
16
|
+
* readActiveIndex so this can be called from chatRunner.cjs's synchronous
|
|
17
|
+
* executeRun() without restructuring it into an async flow.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const os = require('node:os');
|
|
23
|
+
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
24
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
25
|
+
const configMgr = require('../config.cjs');
|
|
26
|
+
|
|
27
|
+
/** The hardcoded floor every branch of resolveEpicModel falls back to — --model must never be omitted (CLAUDE.md "Automation model pinning"). */
|
|
28
|
+
const FALLBACK_MODEL = 'sonnet';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Looks up the agentType an Epic (PromptSession) was created with, by the
|
|
32
|
+
* Epic's own claudeSessionId — the same domain-model join `prdCreate.cjs`'s
|
|
33
|
+
* resolveSourcePromptIdFromClaudeSession uses (Epic:claude-session is 1:1).
|
|
34
|
+
* Returns null if no matching Epic is found or it has no agentType set.
|
|
35
|
+
*/
|
|
36
|
+
function findAgentTypeByClaudeSessionId(cwd, claudeSessionId, deps = {}) {
|
|
37
|
+
if (!cwd || !claudeSessionId) return null;
|
|
38
|
+
const load = deps.readActiveIndex || readActiveIndex;
|
|
39
|
+
const { sessions } = load(cwd);
|
|
40
|
+
for (const session of Object.values(sessions)) {
|
|
41
|
+
if (session && session.claudeSessionId === claudeSessionId) return session.agentType || null;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Reads a global agent persona's `model` frontmatter field by name
|
|
48
|
+
* (`~/.claude/agents/<agentType>.md`), sync. Returns null on any miss
|
|
49
|
+
* (no such file, unreadable, no `model` key) — never throws.
|
|
50
|
+
*/
|
|
51
|
+
function readPersonaModel(agentType, deps = {}) {
|
|
52
|
+
if (!agentType) return null;
|
|
53
|
+
const globalDir = deps.globalDir || path.join(os.homedir(), '.claude', 'agents');
|
|
54
|
+
const validatePath = deps.validatePath || configMgr.validatePath;
|
|
55
|
+
let real;
|
|
56
|
+
try {
|
|
57
|
+
real = validatePath(path.join(globalDir, `${agentType}.md`));
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
let text;
|
|
62
|
+
try {
|
|
63
|
+
text = fs.readFileSync(real, 'utf8');
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const { fm } = splitFrontmatter(text);
|
|
68
|
+
return fm.model || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolves the `--model` value for an Epic-backed session launch. Never
|
|
73
|
+
* throws — any lookup failure (missing Epic, missing persona, unreadable
|
|
74
|
+
* file) falls back to `fallbackModel` (default: 'sonnet', the same literal
|
|
75
|
+
* every claude -p call site pins per CLAUDE.md's model-pinning rule).
|
|
76
|
+
*
|
|
77
|
+
* @param {{ cwd: string, claudeSessionId: string, fallbackModel?: string, deps?: object }} opts
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function resolveEpicModel({ cwd, claudeSessionId, fallbackModel = FALLBACK_MODEL, deps = {} } = {}) {
|
|
81
|
+
try {
|
|
82
|
+
const agentType = findAgentTypeByClaudeSessionId(cwd, claudeSessionId, deps);
|
|
83
|
+
if (!agentType) return fallbackModel;
|
|
84
|
+
const model = readPersonaModel(agentType, deps);
|
|
85
|
+
if (!model || model === 'inherit') return fallbackModel;
|
|
86
|
+
return model;
|
|
87
|
+
} catch {
|
|
88
|
+
return fallbackModel;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
FALLBACK_MODEL,
|
|
94
|
+
resolveEpicModel,
|
|
95
|
+
findAgentTypeByClaudeSessionId,
|
|
96
|
+
readPersonaModel,
|
|
97
|
+
};
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* survive even if the project's ops root doesn't exist yet.
|
|
9
9
|
*
|
|
10
10
|
* Best-effort only: a logging failure must never block the mint/create it is
|
|
11
|
-
* observing (same posture as
|
|
11
|
+
* observing (same posture as rcaReport.cjs / dodDrainHook.cjs).
|
|
12
12
|
*
|
|
13
13
|
* Plain Node module (no Electron deps) so scripts/mint-epic.cjs and
|
|
14
|
-
* scripts/
|
|
14
|
+
* scripts/mint-epic.cjs (which runs outside Electron) can require it too.
|
|
15
15
|
*/
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// Bound on any single string field kept inline in the in-memory `raw`
|
|
4
|
+
// projection stored per event (events are ring-buffered up to 500 deep,
|
|
5
|
+
// see transcripts.cjs's sub.buffer). Anything longer is truncated here —
|
|
6
|
+
// the full untruncated line is still recoverable via the event's `ref`
|
|
7
|
+
// (byte offset/length into the transcript file on disk, see makeEvent).
|
|
3
8
|
const MAX_RAW_STR = 4096;
|
|
4
9
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
10
|
+
// Bound on the short human-scannable preview attached to every event.
|
|
11
|
+
// Deliberately much smaller than MAX_RAW_STR — previewText is for a
|
|
12
|
+
// glance, not a document; the full payload is always one disk read away
|
|
13
|
+
// via `ref`.
|
|
14
|
+
const PREVIEW_CHARS = 280;
|
|
15
|
+
|
|
16
|
+
// Historically tool_use/tool_result blocks were exempted from the
|
|
17
|
+
// MAX_RAW_STR cap so orchestrator.ts/race.ts could structurally re-parse
|
|
18
|
+
// them without a truncated mid-token "…". Both files were deleted
|
|
19
|
+
// 2026-07-30 (commit e3848c3, "delete Subagents-only dead code") along
|
|
20
|
+
// with the rest of the Subagents tab, and no current consumer needs an
|
|
21
|
+
// untruncated tool_result/tool_use block in the in-memory `raw`
|
|
22
|
+
// projection — holding one verbatim (e.g. a large Read tool_result) in a
|
|
23
|
+
// 500-entry ring buffer was a real memory-cliff incident. Every block
|
|
24
|
+
// type is now trimmed the same way; this stays exported (empty) in case
|
|
25
|
+
// a future structural consumer needs to opt a block type back out.
|
|
26
|
+
const EXEMPT_TYPES = new Set();
|
|
9
27
|
|
|
10
28
|
/**
|
|
11
|
-
* Cap string fields in a content block array so arbitrary tool output
|
|
12
|
-
* bloat the ring buffer. Blocks whose type is in EXEMPT_TYPES are
|
|
13
|
-
* through intact
|
|
14
|
-
* parsers in race.ts / orchestrator.ts.
|
|
29
|
+
* Cap string fields in a content block array so arbitrary tool output
|
|
30
|
+
* doesn't bloat the ring buffer. Blocks whose type is in EXEMPT_TYPES are
|
|
31
|
+
* passed through intact (currently no type is exempt — see comment above).
|
|
15
32
|
*/
|
|
16
33
|
function trimContentArray(content) {
|
|
17
34
|
if (!Array.isArray(content)) return content;
|
|
@@ -32,58 +49,124 @@ function trimContentArray(content) {
|
|
|
32
49
|
});
|
|
33
50
|
}
|
|
34
51
|
|
|
35
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Build the raw projection carried on every event. Preserves every
|
|
54
|
+
* top-level field on the line (attribution*, effort, gitBranch,
|
|
55
|
+
* isSidechain, isMeta, requestId, isApiErrorMessage,
|
|
56
|
+
* interruptedByShutdown, permissionMode, promptSource, toolUseResult,
|
|
57
|
+
* etc.) — not just message.content — so the renderer can surface all of
|
|
58
|
+
* it, however trivial or redundant. message.content is still run through
|
|
59
|
+
* trimContentArray to bound size in the ring buffer.
|
|
60
|
+
*/
|
|
36
61
|
function makeRaw(obj) {
|
|
37
|
-
|
|
38
|
-
|
|
62
|
+
if (!obj || typeof obj !== 'object') return {};
|
|
63
|
+
const raw = {};
|
|
64
|
+
for (const key of Object.keys(obj)) {
|
|
65
|
+
if (key === 'message') continue;
|
|
66
|
+
raw[key] = obj[key];
|
|
67
|
+
}
|
|
68
|
+
if (obj.message && typeof obj.message === 'object') {
|
|
69
|
+
raw.message = { ...obj.message, content: trimContentArray(obj.message.content) };
|
|
70
|
+
}
|
|
71
|
+
return raw;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Bounded, human-scannable preview of an event's data — never the source of truth. */
|
|
75
|
+
function buildPreviewText(data) {
|
|
76
|
+
let s;
|
|
77
|
+
if (typeof data === 'string') {
|
|
78
|
+
s = data;
|
|
79
|
+
} else {
|
|
80
|
+
try {
|
|
81
|
+
s = JSON.stringify(data);
|
|
82
|
+
} catch {
|
|
83
|
+
s = String(data);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!s) return '';
|
|
87
|
+
return s.length > PREVIEW_CHARS ? s.slice(0, PREVIEW_CHARS) + '…' : s;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build one event. `ref` (optional) is `{ filePath, byteOffset, byteLength }`
|
|
92
|
+
* pointing at the exact bytes of the source line on disk, so the full,
|
|
93
|
+
* untruncated line can be re-read on demand instead of being held in
|
|
94
|
+
* memory. classifyLine stays a pure function of (obj, ref) — callers that
|
|
95
|
+
* don't have file context (e.g. unit tests) may omit ref.
|
|
96
|
+
*/
|
|
97
|
+
function makeEvent(kind, data, obj, ref) {
|
|
98
|
+
return { kind, data, raw: makeRaw(obj), previewText: buildPreviewText(data), ref: ref || null };
|
|
39
99
|
}
|
|
40
100
|
|
|
41
101
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
102
|
+
* Classify one content block and push its event(s) onto `events`. Known
|
|
103
|
+
* tool_use names get a specific kind (todo_write/plan/agent_spawn/tool_use);
|
|
104
|
+
* tool_result blocks get 'tool_result'; text blocks inherit the message's
|
|
105
|
+
* own type (assistant/user/…) so existing type-based consumers keep
|
|
106
|
+
* working. Any other block type — including ones Anthropic hasn't shipped
|
|
107
|
+
* yet — is surfaced under its own `content_<type>` kind rather than
|
|
108
|
+
* silently dropped, so new block kinds stay visible instead of vanishing.
|
|
44
109
|
*/
|
|
45
|
-
function
|
|
46
|
-
if (!
|
|
47
|
-
|
|
110
|
+
function classifyBlock(block, obj, ref, type, events) {
|
|
111
|
+
if (!block || typeof block !== 'object') return;
|
|
112
|
+
if (block.type === 'tool_use') {
|
|
113
|
+
if (block.name === 'TodoWrite') {
|
|
114
|
+
events.push(makeEvent('todo_write', block.input?.todos || block.input || [], obj, ref));
|
|
115
|
+
} else if (block.name === 'ExitPlanMode' || block.name === 'EnterPlanMode') {
|
|
116
|
+
events.push(makeEvent('plan', block.input, obj, ref));
|
|
117
|
+
} else if (block.name === 'Agent' || block.name === 'Task') {
|
|
118
|
+
// Include block.id as toolUseId so the live store can match the
|
|
119
|
+
// corresponding tool_result and update per-agent lastActivityAt.
|
|
120
|
+
events.push(makeEvent('agent_spawn', { ...block.input, toolUseId: block.id }, obj, ref));
|
|
121
|
+
} else {
|
|
122
|
+
events.push(makeEvent('tool_use', { name: block.name, input: block.input, id: block.id }, obj, ref));
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// tool_result carries the tool_use_id of the completed Task/Agent call.
|
|
127
|
+
// The live store uses this to update the agent's lastActivityAt bookend.
|
|
128
|
+
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
129
|
+
events.push(makeEvent('tool_result', { toolUseId: block.tool_use_id }, obj, ref));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
133
|
+
events.push(makeEvent(type || 'text', block.text, obj, ref));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
events.push(makeEvent(block.type ? `content_${block.type}` : 'content_block', block, obj, ref));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse one JSONL line defensively. Real schema drifts, so we pass
|
|
141
|
+
* through anything that parses and tag a coarse `kind` per event.
|
|
142
|
+
*
|
|
143
|
+
* Returns an ARRAY of events (empty array for an unclassifiable line) —
|
|
144
|
+
* a single line can legitimately carry more than one event: a usage
|
|
145
|
+
* rollup alongside real content, or an assistant turn with text plus
|
|
146
|
+
* several tool calls in the same content array. No caller may assume a
|
|
147
|
+
* line produces at most one event.
|
|
148
|
+
*/
|
|
149
|
+
function classifyLine(obj, ref) {
|
|
150
|
+
if (!obj || typeof obj !== 'object') return [];
|
|
151
|
+
const events = [];
|
|
48
152
|
const type = obj.type || obj.event || obj.role;
|
|
49
153
|
const msg = obj.message || obj;
|
|
50
154
|
const content = msg?.content;
|
|
155
|
+
const usage = obj.usage || msg?.usage;
|
|
51
156
|
|
|
52
|
-
// Usage rollups arrive as summary events
|
|
53
|
-
|
|
54
|
-
|
|
157
|
+
// Usage rollups arrive as summary events — emitted alongside whatever
|
|
158
|
+
// content the same line carries, never instead of it.
|
|
159
|
+
if (usage) {
|
|
160
|
+
events.push(makeEvent('usage', usage, obj, ref));
|
|
55
161
|
}
|
|
56
162
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (block.name === 'TodoWrite') {
|
|
62
|
-
return { kind: 'todo_write', data: block.input?.todos || block.input || [], raw: makeRaw(obj) };
|
|
63
|
-
}
|
|
64
|
-
if (block.name === 'ExitPlanMode' || block.name === 'EnterPlanMode') {
|
|
65
|
-
return { kind: 'plan', data: block.input, raw: makeRaw(obj) };
|
|
66
|
-
}
|
|
67
|
-
if (block.name === 'Agent' || block.name === 'Task') {
|
|
68
|
-
// Include block.id as toolUseId so the live store can match the
|
|
69
|
-
// corresponding tool_result and update per-agent lastActivityAt.
|
|
70
|
-
return { kind: 'agent_spawn', data: { ...block.input, toolUseId: block.id }, raw: makeRaw(obj) };
|
|
71
|
-
}
|
|
72
|
-
return {
|
|
73
|
-
kind: 'tool_use',
|
|
74
|
-
data: { name: block.name, input: block.input, id: block.id },
|
|
75
|
-
raw: makeRaw(obj),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
// tool_result carries the tool_use_id of the completed Task/Agent call.
|
|
79
|
-
// The live store uses this to update the agent's lastActivityAt bookend.
|
|
80
|
-
if (block?.type === 'tool_result' && block.tool_use_id) {
|
|
81
|
-
return { kind: 'tool_result', data: { toolUseId: block.tool_use_id }, raw: makeRaw(obj) };
|
|
82
|
-
}
|
|
83
|
-
}
|
|
163
|
+
if (Array.isArray(content) && content.length > 0) {
|
|
164
|
+
for (const block of content) classifyBlock(block, obj, ref, type, events);
|
|
165
|
+
} else if (!usage) {
|
|
166
|
+
events.push(makeEvent(type || 'message', obj, obj, ref));
|
|
84
167
|
}
|
|
85
168
|
|
|
86
|
-
return
|
|
169
|
+
return events;
|
|
87
170
|
}
|
|
88
171
|
|
|
89
|
-
module.exports = { MAX_RAW_STR, EXEMPT_TYPES, trimContentArray, makeRaw, classifyLine };
|
|
172
|
+
module.exports = { MAX_RAW_STR, PREVIEW_CHARS, EXEMPT_TYPES, trimContentArray, makeRaw, buildPreviewText, classifyLine };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* epicContextDigest.cjs — condensed, real (never fabricated) digest of an
|
|
5
|
+
* Epic's own session, for later injection into a scheduled PRD job's opening
|
|
6
|
+
* prompt (wiring is a separate, dependent PRD — this module is groundwork
|
|
7
|
+
* only). Composes the Epic's goalText (active-index.json, via epicMint.cjs's
|
|
8
|
+
* readActiveIndex) with its most-recent turns (the JSONL transcript, via
|
|
9
|
+
* promptSessionTranscript.cjs's readTurns) — never reimplements either read.
|
|
10
|
+
*
|
|
11
|
+
* Plain Node module (no Electron deps), matching sessionSlots.cjs's and
|
|
12
|
+
* promptSessionTranscript.cjs's own posture, so it is callable from
|
|
13
|
+
* scheduler.cjs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { readTurns } = require('../promptSessionTranscript.cjs');
|
|
17
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MAX_CHARS = 4000;
|
|
20
|
+
const TURN_LIMIT = 40;
|
|
21
|
+
|
|
22
|
+
function formatTurn(turn) {
|
|
23
|
+
return `[${turn.role}] ${turn.text}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* buildContextDigest({ cwd, epicId, maxChars }) → Promise<string>
|
|
28
|
+
*
|
|
29
|
+
* Best-effort: every internal call is wrapped so a failure here can never
|
|
30
|
+
* propagate to a caller — returns '' on any error, missing epicId, an epicId
|
|
31
|
+
* absent from the cwd's active-index.json, or an empty transcript.
|
|
32
|
+
*/
|
|
33
|
+
async function buildContextDigest({ cwd, epicId, maxChars } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
if (!cwd || !epicId) return '';
|
|
36
|
+
const limit = typeof maxChars === 'number' && maxChars > 0 ? maxChars : DEFAULT_MAX_CHARS;
|
|
37
|
+
|
|
38
|
+
const index = readActiveIndex(cwd);
|
|
39
|
+
if (!Object.prototype.hasOwnProperty.call(index.sessions, epicId)) return '';
|
|
40
|
+
const session = index.sessions[epicId];
|
|
41
|
+
const goalText = session && typeof session.goalText === 'string' ? session.goalText.trim() : '';
|
|
42
|
+
|
|
43
|
+
const turns = await readTurns(cwd, epicId, { limit: TURN_LIMIT });
|
|
44
|
+
|
|
45
|
+
const header = goalText;
|
|
46
|
+
const headerChars = header ? header.length + 1 : 0; // +1 for trailing newline before turns
|
|
47
|
+
|
|
48
|
+
// Drop whole turns from the oldest first until the joined digest fits —
|
|
49
|
+
// never a hard mid-string slice that could cut a turn's text in half.
|
|
50
|
+
let kept = turns.slice();
|
|
51
|
+
while (kept.length > 0) {
|
|
52
|
+
const lines = kept.map(formatTurn);
|
|
53
|
+
const bodyChars = lines.length > 0 ? lines.join('\n').length : 0;
|
|
54
|
+
const total = headerChars + bodyChars;
|
|
55
|
+
if (total <= limit) break;
|
|
56
|
+
kept = kept.slice(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const parts = [];
|
|
60
|
+
if (header) parts.push(header);
|
|
61
|
+
if (kept.length > 0) parts.push(kept.map(formatTurn).join('\n'));
|
|
62
|
+
|
|
63
|
+
let digest = parts.join('\n');
|
|
64
|
+
if (digest.length > limit) {
|
|
65
|
+
// Header alone (no turns fit) can still exceed maxChars — clip the
|
|
66
|
+
// header itself rather than exceeding the caller's bound.
|
|
67
|
+
digest = digest.slice(0, limit);
|
|
68
|
+
}
|
|
69
|
+
return digest;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.error('[epicContextDigest] buildContextDigest error', cwd, epicId, e);
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
buildContextDigest,
|
|
78
|
+
};
|