@yeaft/webchat-agent 0.1.594 → 0.1.595

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.594",
3
+ "version": "0.1.595",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,158 @@
1
+ /**
2
+ * archive/tool-results.js — DESIGN.md §4.3.
3
+ *
4
+ * Tool results balloon context (file dumps, web pages, grep output).
5
+ * When `turn_age > 5` AND content length > 2000 chars, archive the body
6
+ * to `…/archive/tool-results/<toolCallId>.md` and replace it in-place
7
+ * with a stub. The `[user, assistant(toolCalls), tool…]` pairing is
8
+ * preserved (the stub is still a `role:'tool'` message with the same
9
+ * `toolCallId`) so the engine never trips the OpenAI/Anthropic schema.
10
+ *
11
+ * This module is pure I/O + bookkeeping. It does NOT mutate the
12
+ * messages array directly; it returns a new array so callers can swap
13
+ * atomically.
14
+ */
15
+
16
+ import { promises as fs } from 'fs';
17
+ import { join, dirname } from 'path';
18
+
19
+ const DEFAULT_TURN_AGE_MIN = 5;
20
+ const DEFAULT_LENGTH_MIN = 2000;
21
+ const STUB_PREVIEW_LEN = 200;
22
+
23
+ /**
24
+ * Where a single archived body lives. The "scope" here is the directory
25
+ * containing the `archive/` folder — typically a group dir
26
+ * (`groups/<gid>`) but task-scoped tools archive under `tasks/<tid>/`.
27
+ *
28
+ * @param {{ root: string, scopeDir: string, toolCallId: string }} args
29
+ * @returns {string}
30
+ */
31
+ export function toolArchivePath({ root, scopeDir, toolCallId }) {
32
+ if (!root || !scopeDir || !toolCallId) {
33
+ throw new Error('toolArchivePath: root + scopeDir + toolCallId required');
34
+ }
35
+ return join(root, scopeDir, 'archive', 'tool-results', `${toolCallId}.md`);
36
+ }
37
+
38
+ /**
39
+ * Compute the per-message `turn_age` for tool messages: how many
40
+ * `user` messages have appeared *after* the tool message (1-based — a
41
+ * tool message produced this turn has age 0).
42
+ *
43
+ * @param {object[]} messages
44
+ * @returns {number[]} same length as messages; age 0 for non-tool entries
45
+ */
46
+ export function computeTurnAges(messages) {
47
+ if (!Array.isArray(messages)) return [];
48
+ const ages = new Array(messages.length).fill(0);
49
+ let userSeen = 0;
50
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
51
+ const m = messages[i];
52
+ if (m && m.role === 'user') {
53
+ userSeen += 1;
54
+ continue;
55
+ }
56
+ if (m && m.role === 'tool') ages[i] = userSeen;
57
+ }
58
+ return ages;
59
+ }
60
+
61
+ /**
62
+ * @param {{
63
+ * root: string,
64
+ * scopeDir: string,
65
+ * message: object,
66
+ * }} args
67
+ * @returns {Promise<{ stub: object, archivedBytes: number, path: string }>}
68
+ */
69
+ export async function archiveOne({ root, scopeDir, message }) {
70
+ if (!message || message.role !== 'tool') {
71
+ throw new Error('archiveOne: tool message required');
72
+ }
73
+ const toolCallId = message.toolCallId;
74
+ if (!toolCallId) throw new Error('archiveOne: toolCallId required');
75
+ const body = typeof message.content === 'string' ? message.content : JSON.stringify(message.content);
76
+ const path = toolArchivePath({ root, scopeDir, toolCallId });
77
+ await fs.mkdir(dirname(path), { recursive: true });
78
+ await fs.writeFile(path, body, 'utf8');
79
+ const sizeStr = formatSize(body.length);
80
+ const preview = body.slice(0, STUB_PREVIEW_LEN).replace(/\s+/g, ' ');
81
+ const stub = {
82
+ role: 'tool',
83
+ toolCallId,
84
+ content: `[archived: ${sizeStr}; preview: "${preview}"; retrieve via tool_trace("${toolCallId}")]`,
85
+ isError: !!message.isError,
86
+ };
87
+ return { stub, archivedBytes: body.length, path };
88
+ }
89
+
90
+ /**
91
+ * Read a previously-archived tool result body.
92
+ *
93
+ * @param {{ root: string, scopeDir: string, toolCallId: string }} args
94
+ * @returns {Promise<string|null>} body, or null if not found
95
+ */
96
+ export async function readArchivedTool({ root, scopeDir, toolCallId }) {
97
+ const path = toolArchivePath({ root, scopeDir, toolCallId });
98
+ try {
99
+ return await fs.readFile(path, 'utf8');
100
+ } catch (err) {
101
+ if (err && err.code === 'ENOENT') return null;
102
+ throw err;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Sweep messages, archive every tool message that meets the age/length
108
+ * thresholds, return a new messages array with stubs swapped in. Untouched
109
+ * messages keep object identity.
110
+ *
111
+ * @param {{
112
+ * root: string,
113
+ * scopeDir: string,
114
+ * messages: object[],
115
+ * turnAgeMin?: number,
116
+ * lengthMin?: number,
117
+ * }} args
118
+ * @returns {Promise<{
119
+ * nextMessages: object[],
120
+ * archivedCount: number,
121
+ * archivedBytes: number,
122
+ * }>}
123
+ */
124
+ export async function archiveToolResults({
125
+ root, scopeDir, messages,
126
+ turnAgeMin = DEFAULT_TURN_AGE_MIN, lengthMin = DEFAULT_LENGTH_MIN,
127
+ }) {
128
+ if (!Array.isArray(messages)) throw new Error('archiveToolResults: messages array required');
129
+ const ages = computeTurnAges(messages);
130
+ let mutated = false;
131
+ let archivedCount = 0;
132
+ let archivedBytes = 0;
133
+ const out = messages.slice();
134
+ for (let i = 0; i < out.length; i += 1) {
135
+ const m = out[i];
136
+ if (!m || m.role !== 'tool' || !m.toolCallId) continue;
137
+ if (typeof m.content !== 'string') continue;
138
+ if (m.content.startsWith('[archived:')) continue; // already a stub
139
+ if (ages[i] <= turnAgeMin) continue;
140
+ if (m.content.length <= lengthMin) continue;
141
+ const r = await archiveOne({ root, scopeDir, message: m });
142
+ out[i] = r.stub;
143
+ archivedCount += 1;
144
+ archivedBytes += r.archivedBytes;
145
+ mutated = true;
146
+ }
147
+ return {
148
+ nextMessages: mutated ? out : messages,
149
+ archivedCount,
150
+ archivedBytes,
151
+ };
152
+ }
153
+
154
+ function formatSize(bytes) {
155
+ if (bytes < 1024) return `${bytes}B`;
156
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
157
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
158
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * archive/trace.js — DESIGN.md §4.4.
3
+ *
4
+ * Retrieval helpers backing the `tool_trace` / `message_trace` tools.
5
+ * The tool-registry wires these into the worker's toolset; this module
6
+ * is a thin wrapper around the archive readers with the ACL gate in
7
+ * place (only `vp/<other>/` is hard-blocked, per §1.2).
8
+ *
9
+ * Per design: the tools take a `toolCallId` / `turnId` plus the scope
10
+ * context (the worker passes its current scope dir). We never search
11
+ * across scopes — the caller knows which group/task/VP the lookup is
12
+ * for. Cross-VP lookups (`scopeDir = 'vp/<other>'`) throw `acl_blocked`.
13
+ */
14
+
15
+ import { readArchivedTool } from './tool-results.js';
16
+ import { readArchivedTurn } from './turn-archive.js';
17
+
18
+ const VP_PREFIX = 'vp/';
19
+
20
+ /**
21
+ * @param {string} scopeDir e.g. 'vp/grace' or 'groups/eng' or 'tasks/t_1'
22
+ * @param {string|null|undefined} currentVpId the VP making the call
23
+ */
24
+ function aclCheck(scopeDir, currentVpId) {
25
+ if (typeof scopeDir !== 'string' || !scopeDir) {
26
+ throw new Error('trace: scopeDir required');
27
+ }
28
+ if (!scopeDir.startsWith(VP_PREFIX)) return;
29
+ const owner = scopeDir.slice(VP_PREFIX.length).split('/')[0];
30
+ if (!owner) return;
31
+ if (currentVpId && owner !== currentVpId) {
32
+ const e = new Error(`acl_blocked: ${scopeDir}`);
33
+ /** @type {any} */ (e).code = 'acl_blocked';
34
+ throw e;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {{
40
+ * root: string,
41
+ * scopeDir: string,
42
+ * toolCallId: string,
43
+ * currentVpId?: string,
44
+ * }} args
45
+ * @returns {Promise<{ ok: boolean, body?: string, error?: string }>}
46
+ */
47
+ export async function toolTrace({ root, scopeDir, toolCallId, currentVpId }) {
48
+ aclCheck(scopeDir, currentVpId);
49
+ if (!toolCallId) return { ok: false, error: 'missing_toolCallId' };
50
+ const body = await readArchivedTool({ root, scopeDir, toolCallId });
51
+ if (body == null) return { ok: false, error: 'not_found' };
52
+ return { ok: true, body };
53
+ }
54
+
55
+ /**
56
+ * @param {{
57
+ * root: string,
58
+ * scopeDir: string,
59
+ * turnId: string,
60
+ * currentVpId?: string,
61
+ * }} args
62
+ * @returns {Promise<{ ok: boolean, header?: object, messages?: object[], error?: string }>}
63
+ */
64
+ export async function messageTrace({ root, scopeDir, turnId, currentVpId }) {
65
+ aclCheck(scopeDir, currentVpId);
66
+ if (!turnId) return { ok: false, error: 'missing_turnId' };
67
+ const out = await readArchivedTurn({ root, scopeDir, turnId });
68
+ if (!out) return { ok: false, error: 'not_found' };
69
+ return { ok: true, header: out.header, messages: out.messages };
70
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * archive/turn-archive.js — DESIGN.md §4.2 + §4.4.
3
+ *
4
+ * When compact archives a cooling turn-group, the full content is
5
+ * written to `…/archive/<turnId>.md` so a worker can later replay it via
6
+ * `message_trace({turnId})`. Each archived turn is one markdown file
7
+ * with a YAML-ish header followed by JSON-encoded message bodies. We
8
+ * keep the format intentionally simple: any caller that can read JSON
9
+ * lines can replay it.
10
+ *
11
+ * Format:
12
+ *
13
+ * ---
14
+ * turnId: <id>
15
+ * archivedAt: <ISO>
16
+ * messageCount: <n>
17
+ * ---
18
+ * <line-delimited JSON, one message per line>
19
+ *
20
+ * We do NOT strip `_meta` here — DESIGN.md §9.15 says "Compact archive
21
+ * carries `_meta` into the archived turn — useful for `message_trace`
22
+ * replays".
23
+ */
24
+
25
+ import { promises as fs } from 'fs';
26
+ import { join, dirname } from 'path';
27
+
28
+ /**
29
+ * @param {{ root: string, scopeDir: string, turnId: string }} args
30
+ */
31
+ export function turnArchivePath({ root, scopeDir, turnId }) {
32
+ if (!root || !scopeDir || !turnId) {
33
+ throw new Error('turnArchivePath: root + scopeDir + turnId required');
34
+ }
35
+ return join(root, scopeDir, 'archive', `${turnId}.md`);
36
+ }
37
+
38
+ /**
39
+ * @param {{
40
+ * root: string,
41
+ * scopeDir: string,
42
+ * turnId: string,
43
+ * messages: object[],
44
+ * archivedAt?: string,
45
+ * }} args
46
+ * @returns {Promise<{ path: string, byteLength: number }>}
47
+ */
48
+ export async function archiveTurn({ root, scopeDir, turnId, messages, archivedAt }) {
49
+ if (!Array.isArray(messages)) throw new Error('archiveTurn: messages array required');
50
+ const path = turnArchivePath({ root, scopeDir, turnId });
51
+ await fs.mkdir(dirname(path), { recursive: true });
52
+ const header = [
53
+ '---',
54
+ `turnId: ${turnId}`,
55
+ `archivedAt: ${archivedAt || new Date().toISOString()}`,
56
+ `messageCount: ${messages.length}`,
57
+ '---',
58
+ '',
59
+ ].join('\n');
60
+ const body = messages.map(m => JSON.stringify(m)).join('\n');
61
+ const content = header + body + (messages.length ? '\n' : '');
62
+ await fs.writeFile(path, content, 'utf8');
63
+ return { path, byteLength: content.length };
64
+ }
65
+
66
+ /**
67
+ * @param {{ root: string, scopeDir: string, turnId: string }} args
68
+ * @returns {Promise<{ header: object, messages: object[] } | null>}
69
+ */
70
+ export async function readArchivedTurn({ root, scopeDir, turnId }) {
71
+ const path = turnArchivePath({ root, scopeDir, turnId });
72
+ let content;
73
+ try {
74
+ content = await fs.readFile(path, 'utf8');
75
+ } catch (err) {
76
+ if (err && err.code === 'ENOENT') return null;
77
+ throw err;
78
+ }
79
+ const m = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
80
+ if (!m) return { header: {}, messages: [] };
81
+ const header = {};
82
+ for (const ln of m[1].split('\n')) {
83
+ const idx = ln.indexOf(':');
84
+ if (idx < 0) continue;
85
+ header[ln.slice(0, idx).trim()] = ln.slice(idx + 1).trim();
86
+ }
87
+ const messages = [];
88
+ for (const ln of m[2].split('\n')) {
89
+ if (!ln.trim()) continue;
90
+ try {
91
+ messages.push(JSON.parse(ln));
92
+ } catch {
93
+ // Skip torn line.
94
+ }
95
+ }
96
+ return { header, messages };
97
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * compact/decisions-log.js — DESIGN.md §9.1.
3
+ *
4
+ * Per-task append-only "decisions log". When two VPs run in parallel
5
+ * for the same `targetTaskId` (e.g. a router fan-out), both may want to
6
+ * write a decision. Atomic file create on `entries/*.md` solves the
7
+ * single-entry case; the decisions log is the sequencing layer that
8
+ * lets compact (track 2) read a chronological record and produce one
9
+ * canonical `tasks/<tid>/summary.md`.
10
+ *
11
+ * Format: line-delimited JSON (`decisions.jsonl`). Each line:
12
+ *
13
+ * {"ts": "<ISO>", "vpId": "<id>", "kind": "<decision-kind>", "text": "<…>"}
14
+ *
15
+ * Append uses `fs.appendFile` which is atomic for small writes on
16
+ * POSIX. Readers tolerate partial last-line corruption (very unlikely
17
+ * but cheap to handle): unparseable lines are skipped with a warning.
18
+ */
19
+
20
+ import { promises as fs } from 'fs';
21
+ import { join, dirname } from 'path';
22
+
23
+ const FILE = 'decisions.jsonl';
24
+
25
+ /**
26
+ * @param {string} root
27
+ * @param {string} taskId
28
+ * @returns {string} absolute path
29
+ */
30
+ export function decisionsLogPath(root, taskId) {
31
+ if (!root) throw new Error('decisionsLogPath: root required');
32
+ if (!taskId) throw new Error('decisionsLogPath: taskId required');
33
+ return join(root, 'tasks', taskId, FILE);
34
+ }
35
+
36
+ /**
37
+ * Append a decision row. Creates the parent directory on first write.
38
+ *
39
+ * @param {{
40
+ * root: string,
41
+ * taskId: string,
42
+ * vpId: string,
43
+ * kind: string,
44
+ * text: string,
45
+ * ts?: string,
46
+ * }} args
47
+ * @returns {Promise<{ path: string, line: string }>}
48
+ */
49
+ export async function appendDecision({ root, taskId, vpId, kind, text, ts }) {
50
+ if (!vpId) throw new Error('appendDecision: vpId required');
51
+ if (!kind) throw new Error('appendDecision: kind required');
52
+ const path = decisionsLogPath(root, taskId);
53
+ await fs.mkdir(dirname(path), { recursive: true });
54
+ const row = {
55
+ ts: ts || new Date().toISOString(),
56
+ vpId,
57
+ kind,
58
+ text: typeof text === 'string' ? text : '',
59
+ };
60
+ const line = JSON.stringify(row) + '\n';
61
+ await fs.appendFile(path, line, 'utf8');
62
+ return { path, line };
63
+ }
64
+
65
+ /**
66
+ * Read the decisions log. Returns [] on missing file. Skips lines that
67
+ * fail JSON parse (logged via console.warn) so a torn write never
68
+ * blocks the reader.
69
+ *
70
+ * @param {{ root: string, taskId: string }} args
71
+ * @returns {Promise<Array<{ts: string, vpId: string, kind: string, text: string}>>}
72
+ */
73
+ export async function readDecisions({ root, taskId }) {
74
+ const path = decisionsLogPath(root, taskId);
75
+ let content;
76
+ try {
77
+ content = await fs.readFile(path, 'utf8');
78
+ } catch (err) {
79
+ if (err && err.code === 'ENOENT') return [];
80
+ throw err;
81
+ }
82
+ const out = [];
83
+ const lines = content.split('\n');
84
+ for (const ln of lines) {
85
+ if (!ln.trim()) continue;
86
+ try {
87
+ const row = JSON.parse(ln);
88
+ if (row && typeof row === 'object') out.push(row);
89
+ } catch {
90
+ // Tolerate the very last line being torn; warn but continue.
91
+ // (We could distinguish "torn last line" vs "corrupt mid-line"
92
+ // but the read path doesn't need to.)
93
+ // eslint-disable-next-line no-console
94
+ console.warn(`decisions-log: skipped unparseable line in ${path}`);
95
+ }
96
+ }
97
+ return out;
98
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * compact/orchestrator.js — DESIGN.md §4.2.
3
+ *
4
+ * One trigger, one pass, three tracks. The orchestrator owns the
5
+ * sequencing; the actual LLM-driven summarisation and extraction are
6
+ * supplied as injectables so this file stays small, deterministic, and
7
+ * testable without network.
8
+ *
9
+ * Track 1 — message compaction (always runs):
10
+ * 1. Find the cooling turn-groups (older than the hot window).
11
+ * 2. Generate a `compact_summary` of those groups.
12
+ * 3. Archive each cooling group atomically; replace it in the live
13
+ * messages array with a single placeholder message.
14
+ *
15
+ * Track 2 — task summary refresh (when `taskId` is provided):
16
+ * 4. Refresh `tasks/<tid>/summary.md` from the cooling groups + the
17
+ * prior summary. Atomic via the existing `writeSummary` helper.
18
+ *
19
+ * Track 3 — memory extraction (always runs):
20
+ * 5. Extract durable facts/lessons/preferences and write them as
21
+ * scope entries through the supplied `extract` callback. The
22
+ * callback owns scope routing and `index.md` upserts; this file
23
+ * just hands it the cooling groups.
24
+ *
25
+ * Atomicity rule (§9.2): each cooling turn-group is archived as a
26
+ * unit. We never break a `[user, assistant(toolCalls), tool…]` triple.
27
+ * Track 1 is the only place that mutates `messages`.
28
+ */
29
+
30
+ import { groupTurns, pickCoolingGroups, indicesFromGroups } from './turn-group.js';
31
+ import { writeSummary } from '../memory/scope-tree.js';
32
+
33
+ /**
34
+ * @typedef {{
35
+ * summarise: (coolingMessages: object[]) => Promise<string>,
36
+ * archive: (groupIndex: number, coolingMessages: object[]) => Promise<{ turnId: string }>,
37
+ * extract?: (coolingMessages: object[]) => Promise<{ written: number }>,
38
+ * refreshTaskSummary?: (coolingMessages: object[], priorSummary: string) => Promise<string>,
39
+ * readPriorTaskSummary?: () => Promise<string>,
40
+ * }} CompactHooks
41
+ */
42
+
43
+ /**
44
+ * @param {{
45
+ * messages: object[],
46
+ * keepHot?: number,
47
+ * taskId?: string | null,
48
+ * root?: string,
49
+ * hooks: CompactHooks,
50
+ * }} args
51
+ * @returns {Promise<{
52
+ * archivedGroups: number,
53
+ * archivedMessages: number,
54
+ * compactSummary: string,
55
+ * extractedCount: number,
56
+ * taskSummaryRefreshed: boolean,
57
+ * nextMessages: object[],
58
+ * }>}
59
+ */
60
+ export async function runCompact({ messages, keepHot = 10, taskId = null, root, hooks }) {
61
+ if (!Array.isArray(messages)) {
62
+ throw new Error('runCompact: messages array required');
63
+ }
64
+ if (!hooks || typeof hooks !== 'object') {
65
+ throw new Error('runCompact: hooks required');
66
+ }
67
+ if (typeof hooks.summarise !== 'function') {
68
+ throw new Error('runCompact: hooks.summarise required');
69
+ }
70
+ if (typeof hooks.archive !== 'function') {
71
+ throw new Error('runCompact: hooks.archive required');
72
+ }
73
+
74
+ const groups = groupTurns(messages);
75
+ const { hot, cooling } = pickCoolingGroups(groups, keepHot);
76
+
77
+ // Nothing to compact — return early. We still report `nextMessages` so
78
+ // callers can treat the result uniformly (no copy unless we changed
79
+ // anything).
80
+ if (cooling.length === 0) {
81
+ return {
82
+ archivedGroups: 0,
83
+ archivedMessages: 0,
84
+ compactSummary: '',
85
+ extractedCount: 0,
86
+ taskSummaryRefreshed: false,
87
+ nextMessages: messages,
88
+ };
89
+ }
90
+
91
+ // Slice out the cooling messages once for the summariser/extractor.
92
+ const coolingIdx = indicesFromGroups(cooling);
93
+ const coolingMessages = coolingIdx.map(i => messages[i]);
94
+
95
+ // Track 1.2 — summarise.
96
+ const compactSummary = await hooks.summarise(coolingMessages);
97
+
98
+ // Track 1.3 — archive each cooling group atomically.
99
+ const archiveResults = [];
100
+ for (let i = 0; i < cooling.length; i += 1) {
101
+ const g = cooling[i];
102
+ const groupMsgs = messages.slice(g.start, g.end);
103
+ const r = await hooks.archive(i, groupMsgs);
104
+ archiveResults.push({ ...g, turnId: r?.turnId });
105
+ }
106
+
107
+ // Track 2 — refresh task summary if applicable.
108
+ let taskSummaryRefreshed = false;
109
+ if (taskId && root && typeof hooks.refreshTaskSummary === 'function') {
110
+ const prior = typeof hooks.readPriorTaskSummary === 'function'
111
+ ? await hooks.readPriorTaskSummary() : '';
112
+ const next = await hooks.refreshTaskSummary(coolingMessages, prior);
113
+ if (typeof next === 'string' && next.trim()) {
114
+ await writeSummary({ kind: 'task', id: taskId }, next, { root });
115
+ taskSummaryRefreshed = true;
116
+ }
117
+ }
118
+
119
+ // Track 3 — memory extraction.
120
+ let extractedCount = 0;
121
+ if (typeof hooks.extract === 'function') {
122
+ const extractResult = await hooks.extract(coolingMessages);
123
+ if (extractResult && Number.isFinite(extractResult.written)) {
124
+ extractedCount = extractResult.written;
125
+ }
126
+ }
127
+
128
+ // Track 1.3 (cont.) — produce the new messages array with the cooling
129
+ // window replaced by a single `compact_summary` placeholder.
130
+ const placeholder = {
131
+ role: 'system',
132
+ kind: 'compact_summary',
133
+ content: compactSummary || '',
134
+ };
135
+ // Hot starts at the index right after the last cooling group.
136
+ const cutoff = cooling[cooling.length - 1].end;
137
+ const nextMessages = [placeholder, ...messages.slice(cutoff)];
138
+
139
+ return {
140
+ archivedGroups: cooling.length,
141
+ archivedMessages: coolingIdx.length,
142
+ compactSummary: compactSummary || '',
143
+ extractedCount,
144
+ taskSummaryRefreshed,
145
+ nextMessages,
146
+ };
147
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * compact/triggers.js — DESIGN.md §4.1.
3
+ *
4
+ * Pure functions: do I need to compact, and which triggers fired?
5
+ * Caller (orchestrator) decides what to do next; this module never
6
+ * touches disk or messages.
7
+ */
8
+
9
+ const DEFAULT_MAX_MESSAGES = 50;
10
+ const DEFAULT_TOKEN_RATIO = 0.9;
11
+ const DEFAULT_IDLE_MS = 2 * 60 * 1000;
12
+
13
+ /**
14
+ * @param {{
15
+ * messages: object[],
16
+ * tokenCount: number,
17
+ * contextLimit: number,
18
+ * lastActivityAt?: number,
19
+ * now?: number,
20
+ * explicit?: boolean,
21
+ * maxMessages?: number,
22
+ * tokenRatio?: number,
23
+ * idleMs?: number,
24
+ * }} state
25
+ * @returns {{ trigger: boolean, reasons: string[] }}
26
+ */
27
+ export function evaluateCompactTriggers(state = {}) {
28
+ const reasons = [];
29
+ const messages = Array.isArray(state.messages) ? state.messages : [];
30
+ const tokenCount = Number.isFinite(state.tokenCount) ? state.tokenCount : 0;
31
+ const contextLimit = Number.isFinite(state.contextLimit) && state.contextLimit > 0
32
+ ? state.contextLimit : 0;
33
+ const tokenRatio = Number.isFinite(state.tokenRatio) ? state.tokenRatio : DEFAULT_TOKEN_RATIO;
34
+ const maxMessages = Number.isFinite(state.maxMessages) ? state.maxMessages : DEFAULT_MAX_MESSAGES;
35
+ const idleMs = Number.isFinite(state.idleMs) ? state.idleMs : DEFAULT_IDLE_MS;
36
+
37
+ if (state.explicit) reasons.push('explicit');
38
+
39
+ if (contextLimit > 0 && tokenCount > tokenRatio * contextLimit) {
40
+ reasons.push('token_threshold');
41
+ }
42
+
43
+ if (messages.length > maxMessages) {
44
+ reasons.push('message_count');
45
+ }
46
+
47
+ if (Number.isFinite(state.lastActivityAt) && Number.isFinite(state.now)) {
48
+ if (state.now - state.lastActivityAt > idleMs) {
49
+ reasons.push('idle');
50
+ }
51
+ }
52
+
53
+ return { trigger: reasons.length > 0, reasons };
54
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * compact/turn-group.js — DESIGN.md §9.2.
3
+ *
4
+ * Group messages into turns whose unit of archiving is "atomic": each
5
+ * group is either kept entirely live or archived entirely. Archiving
6
+ * an assistant message that contained `toolCalls` while leaving the
7
+ * tool results live (or vice versa) breaks the OpenAI invariant that
8
+ * `tool_call_id`s must be paired.
9
+ *
10
+ * A "turn group" starts on a `user` message and extends through every
11
+ * subsequent assistant + tool message until the next user message. The
12
+ * grouping function returns an array of `{ start, end, indices }` pairs
13
+ * where `[start, end)` is a half-open range over the input array.
14
+ *
15
+ * Edge cases:
16
+ * - Leading non-user messages (e.g. a system or tool prelude written
17
+ * by an init hook) form a group of their own at index 0.
18
+ * - Trailing assistant/tool messages (incomplete turn) form the final
19
+ * group — same rule.
20
+ * - Empty input → empty result.
21
+ */
22
+
23
+ /**
24
+ * @param {object[]} messages
25
+ * @returns {Array<{ start: number, end: number, role: string }>}
26
+ * `role` reflects the group's anchor (the `user` message, if any;
27
+ * otherwise the first message in the group).
28
+ */
29
+ export function groupTurns(messages) {
30
+ if (!Array.isArray(messages) || messages.length === 0) return [];
31
+ const groups = [];
32
+ let cur = { start: 0, end: 0, role: messages[0]?.role || 'unknown' };
33
+ for (let i = 0; i < messages.length; i += 1) {
34
+ const m = messages[i];
35
+ if (!m || typeof m !== 'object') {
36
+ // Treat as belonging to the current group (don't break pairing).
37
+ cur.end = i + 1;
38
+ continue;
39
+ }
40
+ if (m.role === 'user' && cur.end > cur.start) {
41
+ groups.push(cur);
42
+ cur = { start: i, end: i + 1, role: 'user' };
43
+ } else {
44
+ cur.end = i + 1;
45
+ if (m.role === 'user') cur.role = 'user';
46
+ }
47
+ }
48
+ if (cur.end > cur.start) groups.push(cur);
49
+ return groups;
50
+ }
51
+
52
+ /**
53
+ * Pick the cut point: keep `keepHot` newest groups live, return the
54
+ * rest as "cooling" candidates for archive.
55
+ *
56
+ * Returns: `{ hot: groups[], cooling: groups[] }`. Both arrays use the
57
+ * same `{start, end, role}` shape from `groupTurns`.
58
+ *
59
+ * @param {Array<{start: number, end: number, role: string}>} groups
60
+ * @param {number} keepHot
61
+ */
62
+ export function pickCoolingGroups(groups, keepHot = 10) {
63
+ if (!Array.isArray(groups)) return { hot: [], cooling: [] };
64
+ const k = Math.max(0, Math.floor(keepHot));
65
+ if (groups.length <= k) return { hot: groups.slice(), cooling: [] };
66
+ const cut = groups.length - k;
67
+ return {
68
+ hot: groups.slice(cut),
69
+ cooling: groups.slice(0, cut),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Flatten a list of groups back into the underlying message indices.
75
+ *
76
+ * @param {Array<{start: number, end: number}>} groups
77
+ * @returns {number[]}
78
+ */
79
+ export function indicesFromGroups(groups) {
80
+ const out = [];
81
+ for (const g of groups) {
82
+ for (let i = g.start; i < g.end; i += 1) out.push(i);
83
+ }
84
+ return out;
85
+ }