mixdog 0.9.2 → 0.9.3

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 (154) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/build-tui.mjs +13 -1
  4. package/scripts/explore-bench.mjs +124 -0
  5. package/scripts/hook-bus-test.mjs +191 -0
  6. package/scripts/path-suffix-test.mjs +57 -0
  7. package/scripts/recall-bench.mjs +207 -0
  8. package/scripts/tool-smoke.mjs +7 -4
  9. package/src/agents/debugger/AGENT.md +2 -2
  10. package/src/agents/heavy-worker/AGENT.md +20 -11
  11. package/src/agents/reviewer/AGENT.md +2 -2
  12. package/src/agents/worker/AGENT.md +17 -11
  13. package/src/mixdog-session-runtime.mjs +424 -1812
  14. package/src/repl.mjs +5 -5
  15. package/src/rules/agent/30-explorer.md +8 -11
  16. package/src/rules/lead/lead-tool.md +9 -5
  17. package/src/rules/shared/01-tool.md +11 -5
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
  24. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  28. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  29. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  30. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
  31. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
  32. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
  33. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  34. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  35. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  36. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  37. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  39. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  40. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  41. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  42. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  43. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  44. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  45. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  46. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  47. package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
  48. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  50. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  51. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
  54. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  64. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  69. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  75. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  76. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  77. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  79. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  80. package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
  81. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  84. package/src/runtime/channels/index.mjs +14 -233
  85. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  86. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  87. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  88. package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
  89. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  90. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  91. package/src/runtime/memory/index.mjs +314 -359
  92. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  93. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  94. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  95. package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
  96. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  97. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  98. package/src/runtime/memory/lib/memory.mjs +20 -0
  99. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  100. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  101. package/src/runtime/memory/tool-defs.mjs +4 -4
  102. package/src/runtime/shared/abort-controller.mjs +1 -1
  103. package/src/runtime/shared/background-tasks.mjs +2 -3
  104. package/src/runtime/shared/buffered-appender.mjs +149 -0
  105. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  106. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  107. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  108. package/src/runtime/shared/transcript-writer.mjs +29 -2
  109. package/src/session-runtime/config-helpers.mjs +209 -0
  110. package/src/session-runtime/effort.mjs +128 -0
  111. package/src/session-runtime/fs-utils.mjs +10 -0
  112. package/src/session-runtime/model-capabilities.mjs +130 -0
  113. package/src/session-runtime/output-styles.mjs +124 -0
  114. package/src/session-runtime/plugin-mcp.mjs +114 -0
  115. package/src/session-runtime/session-text.mjs +100 -0
  116. package/src/session-runtime/statusline-route.mjs +35 -0
  117. package/src/session-runtime/tool-catalog.mjs +720 -0
  118. package/src/session-runtime/workflow.mjs +358 -0
  119. package/src/standalone/agent-tool.mjs +49 -10
  120. package/src/standalone/channel-worker.mjs +3 -2
  121. package/src/standalone/explore-tool.mjs +10 -3
  122. package/src/standalone/hook-bus.mjs +165 -8
  123. package/src/standalone/opencode-go-login.mjs +121 -0
  124. package/src/standalone/provider-admin.mjs +14 -3
  125. package/src/tui/App.jsx +884 -143
  126. package/src/tui/components/PromptInput.jsx +272 -15
  127. package/src/tui/components/ToolExecution.jsx +20 -10
  128. package/src/tui/components/tool-output-format.mjs +2 -2
  129. package/src/tui/dist/index.mjs +1771 -1947
  130. package/src/tui/engine/agent-envelope.mjs +296 -0
  131. package/src/tui/engine/boot-profile.mjs +21 -0
  132. package/src/tui/engine/labels.mjs +67 -0
  133. package/src/tui/engine/notice-text.mjs +112 -0
  134. package/src/tui/engine/queue-helpers.mjs +161 -0
  135. package/src/tui/engine/session-stats.mjs +46 -0
  136. package/src/tui/engine/tool-call-fields.mjs +23 -0
  137. package/src/tui/engine/tool-result-text.mjs +126 -0
  138. package/src/tui/engine.mjs +311 -851
  139. package/src/tui/input-editing.mjs +58 -8
  140. package/src/tui/input-editing.selection.test.mjs +75 -0
  141. package/src/tui/keyboard-protocol.mjs +2 -2
  142. package/src/tui/lib/voice-recorder.mjs +35 -19
  143. package/src/tui/markdown/format-token.mjs +7 -8
  144. package/src/tui/markdown/format-token.test.mjs +3 -3
  145. package/src/tui/paste-attachments.mjs +38 -0
  146. package/src/tui/paste-fix.test.mjs +119 -0
  147. package/src/tui/themes/base.mjs +2 -2
  148. package/src/tui/themes/kanagawa.mjs +4 -4
  149. package/src/tui/themes/teal.mjs +4 -5
  150. package/src/tui/themes/utils.mjs +1 -1
  151. package/src/ui/statusline.mjs +49 -0
  152. package/src/workflows/default/WORKFLOW.md +16 -9
  153. package/src/workflows/sequential/WORKFLOW.md +16 -11
  154. package/src/workflows/solo/WORKFLOW.md +5 -1
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+
5
+ // Plugin version + promotion code fingerprint. Extracted from index.mjs
6
+ // (behavior-preserving). Callers pass PLUGIN_ROOT so this stays free of any
7
+ // entry-point path resolution.
8
+
9
+ export function readPluginVersion(pluginRoot) {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(path.join(pluginRoot, 'package.json'), 'utf8')).version || '0.0.1'
12
+ } catch { return '0.0.1' }
13
+ }
14
+
15
+ const PROMOTION_FINGERPRINT_ROOTS = ['src/memory']
16
+
17
+ function collectPromotionFingerprintFiles(pluginRoot) {
18
+ const out = []
19
+ const walk = (relDir) => {
20
+ let entries = []
21
+ try { entries = fs.readdirSync(path.join(pluginRoot, relDir), { withFileTypes: true }) }
22
+ catch { return }
23
+ for (const ent of entries) {
24
+ const rel = `${relDir}/${ent.name}`.replace(/\\/g, '/')
25
+ if (ent.isDirectory()) {
26
+ walk(rel)
27
+ } else if (ent.isFile() && rel.endsWith('.mjs')) {
28
+ out.push(rel)
29
+ }
30
+ }
31
+ }
32
+ for (const root of PROMOTION_FINGERPRINT_ROOTS) walk(root)
33
+ return out.sort()
34
+ }
35
+
36
+ export function readPromotionCodeFingerprint(pluginRoot) {
37
+ const hash = crypto.createHash('sha256')
38
+ const files = collectPromotionFingerprintFiles(pluginRoot)
39
+ for (const rel of files) {
40
+ hash.update(rel)
41
+ hash.update('\0')
42
+ try {
43
+ hash.update(fs.readFileSync(path.join(pluginRoot, rel)))
44
+ } catch {
45
+ hash.update('missing')
46
+ }
47
+ hash.update('\0')
48
+ }
49
+ return `src/memory:${files.length}:${hash.digest('hex').slice(0, 16)}`
50
+ }
@@ -0,0 +1,183 @@
1
+ import { cleanMemoryText } from './memory.mjs'
2
+
3
+ // Recall query/format helpers extracted verbatim from index.mjs
4
+ // (behavior-preserving). Pure string/date logic plus row rendering.
5
+
6
+ export function parsePeriod(period, hasQuery) {
7
+ if (!period && hasQuery) period = '30d'
8
+ if (!period) return null
9
+ if (period === 'all') return null
10
+ if (period === 'last') return { mode: 'last' }
11
+ // Calendar-day windows: 'today' anchors at local midnight rather than
12
+ // rolling 24h. Without this, a query asking 'today' at 01:30 would silently
13
+ // include yesterday's last 22.5h of activity, mislabelling them as
14
+ // 'today's work'. 'yesterday' is the previous calendar day.
15
+ if (period === 'today') {
16
+ const start = new Date()
17
+ start.setHours(0, 0, 0, 0)
18
+ return { startMs: start.getTime(), endMs: Date.now() }
19
+ }
20
+ if (period === 'yesterday') {
21
+ const start = new Date()
22
+ start.setDate(start.getDate() - 1)
23
+ start.setHours(0, 0, 0, 0)
24
+ const end = new Date(start)
25
+ end.setHours(23, 59, 59, 999)
26
+ return { startMs: start.getTime(), endMs: end.getTime() }
27
+ }
28
+ if (period === 'this_week' || period === 'last_week') {
29
+ // R6 P9: calendar Mon-Sun previous/current week. Mon-start ISO
30
+ // convention. Replaces R5 rolling 7-14d range which was empty for
31
+ // sessions where "last week" decisions actually fell on Mon (4/27) of
32
+ // this week. Precise calendar bounds match natural-language intuition.
33
+ const d = new Date()
34
+ d.setHours(0, 0, 0, 0)
35
+ const dayOfWeek = d.getDay()
36
+ const daysSinceMon = (dayOfWeek + 6) % 7
37
+ const thisWeekMon = new Date(d)
38
+ thisWeekMon.setDate(d.getDate() - daysSinceMon)
39
+ if (period === 'this_week') {
40
+ return { startMs: thisWeekMon.getTime(), endMs: Date.now() }
41
+ }
42
+ const lastWeekMon = new Date(thisWeekMon)
43
+ lastWeekMon.setDate(thisWeekMon.getDate() - 7)
44
+ const lastWeekSunEnd = new Date(thisWeekMon.getTime() - 1)
45
+ return { startMs: lastWeekMon.getTime(), endMs: lastWeekSunEnd.getTime() }
46
+ }
47
+ const relMatch = period.match(/^(\d+)(m|h|d)$/)
48
+ if (relMatch) {
49
+ const n = parseInt(relMatch[1])
50
+ const unit = relMatch[2]
51
+ const now = new Date()
52
+ if (unit === 'm') {
53
+ // Minute granularity is for "resume from the previous turn / pick
54
+ // up where we left off" style recall — sub-hour windows where 1h
55
+ // is too coarse. n=0 is invalid (the regex requires \d+ which
56
+ // matches "0" but a zero-width window returns no rows; leave that
57
+ // as caller-supplied no-op).
58
+ const start = new Date(now.getTime() - n * 60_000)
59
+ return { startMs: start.getTime(), endMs: now.getTime() }
60
+ }
61
+ if (unit === 'h') {
62
+ const start = new Date(now.getTime() - n * 3600_000)
63
+ return { startMs: start.getTime(), endMs: now.getTime() }
64
+ }
65
+ const start = new Date(now)
66
+ start.setDate(start.getDate() - n)
67
+ return { startMs: start.getTime(), endMs: now.getTime() }
68
+ }
69
+ const rangeMatch = period.match(/^(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})$/)
70
+ if (rangeMatch) {
71
+ return {
72
+ startMs: Date.parse(rangeMatch[1] + 'T00:00:00'),
73
+ endMs: Date.parse(rangeMatch[2] + 'T23:59:59.999'),
74
+ }
75
+ }
76
+ const dateMatch = period.match(/^(\d{4}-\d{2}-\d{2})$/)
77
+ if (dateMatch) {
78
+ return {
79
+ startMs: Date.parse(dateMatch[1] + 'T00:00:00'),
80
+ endMs: Date.parse(dateMatch[1] + 'T23:59:59.999'),
81
+ exact: true,
82
+ }
83
+ }
84
+ return null
85
+ }
86
+
87
+ export function formatTs(tsMs) {
88
+ const n = Number(tsMs)
89
+ if (Number.isFinite(n) && n > 1e12) {
90
+ return new Date(n).toLocaleString('sv-SE').slice(0, 16)
91
+ }
92
+ return String(tsMs ?? '').slice(0, 16)
93
+ }
94
+
95
+ export const CORE_RECALL_STOPWORDS = new Set([
96
+ 'about', 'after', 'again', 'before', 'check', 'color', 'decision', 'decided',
97
+ 'earlier', 'memory', 'previous', 'routing', 'stored', 'tell', 'what',
98
+ ])
99
+
100
+ export function coreRecallTerms(query) {
101
+ return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_-]{4,}/gu) || [])]
102
+ .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
103
+ .slice(0, 8)
104
+ }
105
+
106
+ export function normalizeRecallProjectScope(projectScope) {
107
+ const raw = String(projectScope || 'common').trim()
108
+ if (!raw || raw.toLowerCase() === 'common') return null
109
+ if (raw.toLowerCase() === 'all') return '*'
110
+ return raw
111
+ }
112
+
113
+ export function sessionRecallTerms(query) {
114
+ return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_./:-]{2,}/gu) || [])]
115
+ .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
116
+ .slice(0, 12)
117
+ }
118
+
119
+ export function interleaveRawRows(hybridRows, rawRows) {
120
+ if (!Array.isArray(rawRows) || rawRows.length === 0) return hybridRows
121
+ const out = []
122
+ const stride = Math.max(1, Math.round(hybridRows.length / (rawRows.length + 1)))
123
+ let rawIdx = 0
124
+ for (let i = 0; i < hybridRows.length; i += 1) {
125
+ out.push(hybridRows[i])
126
+ if ((i + 1) % stride === 0 && rawIdx < rawRows.length) {
127
+ out.push(rawRows[rawIdx])
128
+ rawIdx += 1
129
+ }
130
+ }
131
+ while (rawIdx < rawRows.length) out.push(rawRows[rawIdx++])
132
+ return out
133
+ }
134
+
135
+ export function renderEntryLines(rows) {
136
+ if (!rows || rows.length === 0) return '(no results)'
137
+ const lines = []
138
+ // Bound total emitted lines (roots x members) so a many-member recall can't
139
+ // inject unbounded output. Per-line content is already capped at 1000 chars;
140
+ // this caps the line COUNT. Narrow the query (limit/period/projectScope) for more.
141
+ const RECALL_LINE_CAP = 200
142
+ let _capped = false
143
+ outer:
144
+ for (const r of rows) {
145
+ const hasMembers = Array.isArray(r.members) && r.members.length > 0
146
+ if (hasMembers) {
147
+ // Chunks present: emit each member as its own line. Root row is a
148
+ // grouping artifact for retrieval — the caller wants the chunk
149
+ // content (cycle1 raw), not the cycle2-compressed summary.
150
+ for (const m of r.members) {
151
+ if (lines.length >= RECALL_LINE_CAP) { _capped = true; break outer }
152
+ const mTs = formatTs(m.ts)
153
+ const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
154
+ const content = cleanMemoryText(String(m.content ?? '')).slice(0, 1000)
155
+ lines.push(`[${mTs}] ${role}: ${content} #${m.id}`)
156
+ }
157
+ } else {
158
+ if (lines.length >= RECALL_LINE_CAP) { _capped = true; break }
159
+ // No chunks (root not yet chunked by cycle1, or orphan leaf): emit
160
+ // the row itself in the same shape. element/summary fall back to
161
+ // raw content when both are absent.
162
+ const ts = formatTs(r.ts)
163
+ const element = r.element ?? ''
164
+ const summary = r.summary ?? ''
165
+ // Standalone leaf rows (is_root=0, no parent chunks_root resolved
166
+ // into a `members` list) carry their u/a role just like inline
167
+ // chunk members — surface it so the format stays consistent across
168
+ // the two emission paths.
169
+ const rolePrefix = r.is_root === 0 && r.role
170
+ ? (r.role === 'user' ? 'u: ' : r.role === 'assistant' ? 'a: ' : `${r.role}: `)
171
+ : ''
172
+ const body = element || summary
173
+ ? `${element}${summary ? ' — ' + summary : ''}`
174
+ : cleanMemoryText(String(r.content ?? '')).slice(0, 1000)
175
+ // Unchunked raw leaf (cycle1 hasn't classified it yet): mark it so
176
+ // callers can tell fresh-but-unprocessed rows from chunked memory.
177
+ const pendingMark = (r.is_root === 0 && r.chunk_root == null) ? ' [pending]' : ''
178
+ lines.push(`[${ts}] ${rolePrefix}${body.slice(0, 1000)}${pendingMark} #${r.id}`)
179
+ }
180
+ }
181
+ if (_capped) lines.push(`[recall truncated — showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
182
+ return lines.join('\n')
183
+ }
@@ -8,12 +8,12 @@ export const TOOL_DEFS = [
8
8
  name: 'memory',
9
9
  title: 'Memory Cycle',
10
10
  annotations: { title: 'Memory Cycle', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
11
- description: 'Memory status/lifecycle or explicit mutation. Use recall for retrieval. Destructive jobs require exact confirm strings.',
11
+ description: 'Memory status/lifecycle or explicit mutation. Use recall for retrieval. When the user explicitly asks to remember something (e.g. "remember this", a stated preference, or a decision), persist it immediately with action:"core" op:"add" — do not wait for a cycle. Destructive jobs require exact confirm strings.',
12
12
  inputSchema: {
13
13
  type: 'object',
14
14
  properties: {
15
15
  action: { type: 'string', enum: ['core','manage','status','sleep','cycle1','cycle2','cycle3','flush','backfill','prune','rebuild','purge','retro_eval_active'], description: 'Operation.' },
16
- op: { type: 'string', enum: ['add','edit','delete','list'], description: 'Mutation op.' },
16
+ op: { type: 'string', enum: ['add','edit','delete','list','candidates','promote','dismiss'], description: 'Mutation op. candidates/promote/dismiss drive core-memory proposal approval.' },
17
17
  id: { type: 'number', description: 'Exact memory id.' },
18
18
  element: { type: 'string', description: 'Memory key/title.' },
19
19
  summary: { type: 'string', description: 'Memory content.' },
@@ -38,13 +38,13 @@ export const TOOL_DEFS = [
38
38
  name: 'recall',
39
39
  title: 'Recall',
40
40
  annotations: { title: 'Recall', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
41
- description: 'Retrieve stored memory/session history. Use for earlier work, resumes, or possibly decided items; when in doubt, recall first.',
41
+ description: 'Retrieve stored memory/session history. Use for earlier work, resumes, or possibly decided items; when in doubt, recall first. For "what did we just discuss": pass sessionId with no query to browse the current session (includes in-progress content); period:"last" without sessionId browses the previous completed session only.',
42
42
  inputSchema: {
43
43
  type: 'object',
44
44
  properties: {
45
45
  query: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Search text, or array for independent fan-out queries.' },
46
46
  id: { anyOf: [{ type: 'number' }, { type: 'array', items: { type: 'number' }, minItems: 1 }], description: 'Exact #id(s) from recall. Do not invent ids.' },
47
- period: { type: 'string', description: 'last, 24h/7d/30d, all, date, or range.' },
47
+ period: { type: 'string', description: "last (previous completed session; pair with sessionId instead to include the current one), 24h/7d/30d, all, date, or range." },
48
48
  limit: { type: 'number', description: 'Max entries.' },
49
49
  offset: { type: 'number', description: 'Skip entries.' },
50
50
  sort: { type: 'string', enum: ['importance', 'date'], description: 'importance or date.' },
@@ -1,5 +1,5 @@
1
1
  /**
2
- * AbortController helpers — ported from a reference agent CLI pattern.
2
+ * AbortController helpers.
3
3
  *
4
4
  * `createAbortController()` raises the signal's max listener cap so long-running
5
5
  * sessions with many per-iteration handlers don't trip Node's default warning.
@@ -5,7 +5,7 @@ import {
5
5
  normalizeToolNotifyContext,
6
6
  notifyToolCompletion,
7
7
  } from './tool-execution-contract.mjs';
8
- import { presentErrorText } from './err-text.mjs';
8
+ import { presentErrorText, errorLine } from './err-text.mjs';
9
9
 
10
10
  export {
11
11
  TOOL_ASYNC_EXECUTION_CONTRACT,
@@ -465,8 +465,7 @@ export function renderBackgroundTask(taskOrId, { includeResult = false } = {}) {
465
465
  if (body) {
466
466
  lines.push('', body);
467
467
  } else if (TERMINAL_STATUSES.has(task.status) && task.error) {
468
- const errorText = String(task.error || '').trim();
469
- lines.push('', /^error\s*:/i.test(errorText) ? errorText : `Error: ${errorText}`);
468
+ lines.push('', errorLine(task.error, { surface: task.surface }));
470
469
  } else if (TERMINAL_STATUSES.has(task.status) && task.status === 'completed') {
471
470
  // Terminal-completed task with no extractable body: surface a placeholder
472
471
  // instead of silently omitting the result so the owner isn't left with a
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Per-file buffered append queue.
3
+ *
4
+ * Hot paths (transcript writer, channel worker stderr log, etc.) previously
5
+ * called appendFileSync per entry/chunk, which blocks the event loop on
6
+ * every write. This module coalesces writes per path into an in-memory
7
+ * buffer and flushes asynchronously via fs.promises.appendFile once the
8
+ * buffer reaches BUFFER_FLUSH_BYTES or after DEBOUNCE_MS of inactivity.
9
+ * Flushes are serialized per path so ordering is preserved. On process
10
+ * exit, any remaining buffered bytes for every path are drained with one
11
+ * synchronous appendFileSync call each (best-effort, matching the
12
+ * agent-trace.mjs local spool exit-drain pattern).
13
+ */
14
+ import { appendFileSync } from 'node:fs';
15
+ import { appendFile } from 'node:fs/promises';
16
+
17
+ const BUFFER_FLUSH_BYTES = 32 * 1024;
18
+ const DEBOUNCE_MS = 50;
19
+
20
+ // path -> { chunks: string[], bytes: number, timer, flushing, inFlight, failCount }
21
+ const queues = new Map();
22
+
23
+ function getQueue(path) {
24
+ let q = queues.get(path);
25
+ if (!q) {
26
+ q = { chunks: [], bytes: 0, timer: null, flushing: false, inFlight: null, failCount: 0 };
27
+ queues.set(path, q);
28
+ }
29
+ return q;
30
+ }
31
+
32
+ function scheduleFlush(path, q) {
33
+ if (q.timer) return;
34
+ q.timer = setTimeout(() => {
35
+ q.timer = null;
36
+ _flush(path, q);
37
+ }, DEBOUNCE_MS);
38
+ q.timer.unref?.();
39
+ }
40
+
41
+ function _flush(path, q) {
42
+ if (q.flushing) return;
43
+ if (q.chunks.length === 0) return;
44
+ const data = q.chunks.join('');
45
+ q.chunks = [];
46
+ q.bytes = 0;
47
+ // Hold the in-flight payload until the write settles so an exit-time
48
+ // sync drain can still recover it if it fires mid-write (fix: exit
49
+ // drain must not miss data that was dequeued but not yet persisted).
50
+ q.inFlight = data;
51
+ q.flushing = true;
52
+ appendFile(path, data, 'utf8')
53
+ .then(() => {
54
+ q.failCount = 0;
55
+ })
56
+ .catch(() => {
57
+ // Requeue the failed data so a later flush/exit-drain retries
58
+ // once, instead of silently dropping. Cap retries so a
59
+ // permanently broken path (e.g. deleted dir) cannot loop
60
+ // forever accumulating the same failing chunk.
61
+ q.failCount += 1;
62
+ if (q.failCount <= 3) {
63
+ q.chunks.unshift(data);
64
+ q.bytes += Buffer.byteLength(data, 'utf8');
65
+ }
66
+ })
67
+ .finally(() => {
68
+ q.inFlight = null;
69
+ q.flushing = false;
70
+ if (q.chunks.length > 0) {
71
+ if (q.bytes >= BUFFER_FLUSH_BYTES) _flush(path, q);
72
+ else scheduleFlush(path, q);
73
+ }
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Queue text for buffered append to `path`. Flushes async once the buffer
79
+ * for that path crosses BUFFER_FLUSH_BYTES, or after DEBOUNCE_MS of
80
+ * inactivity, whichever comes first.
81
+ */
82
+ export function appendBuffered(path, text) {
83
+ if (!path || !text) return;
84
+ const q = getQueue(path);
85
+ q.chunks.push(text);
86
+ q.bytes += Buffer.byteLength(text, 'utf8');
87
+ if (q.bytes >= BUFFER_FLUSH_BYTES) {
88
+ if (q.timer) { clearTimeout(q.timer); q.timer = null; }
89
+ _flush(path, q);
90
+ } else {
91
+ scheduleFlush(path, q);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Synchronously drain any remaining buffered bytes for every known path.
97
+ * Intended for process exit hooks only — best-effort, never throws.
98
+ */
99
+ export function drainAllSync() {
100
+ for (const [path, q] of queues) {
101
+ drainPathSync(path);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Synchronously drain the queue for a single path. Writes any in-flight
107
+ * (already-dequeued but not yet persisted) payload FIRST, then any
108
+ * still-queued chunks, so nothing buffered is lost. Best-effort, never
109
+ * throws.
110
+ */
111
+ export function drainPathSync(path) {
112
+ const q = queues.get(path);
113
+ if (!q) return;
114
+ if (q.timer) { clearTimeout(q.timer); q.timer = null; }
115
+ if (q.inFlight) {
116
+ try {
117
+ appendFileSync(path, q.inFlight, 'utf8');
118
+ } catch {
119
+ // Best-effort; nothing to recover from here.
120
+ }
121
+ q.inFlight = null;
122
+ }
123
+ if (q.chunks.length === 0) return;
124
+ const data = q.chunks.join('');
125
+ q.chunks = [];
126
+ q.bytes = 0;
127
+ try {
128
+ appendFileSync(path, data, 'utf8');
129
+ } catch {
130
+ // Best-effort exit drain; nothing to recover from here.
131
+ }
132
+ }
133
+
134
+ /**
135
+ * True if a path currently has an async write in flight. Callers that need
136
+ * to rename/rotate the underlying file (which would race a concurrent
137
+ * appendFile on some platforms, notably Windows) should check this and
138
+ * defer the rename to a later tick rather than racing it.
139
+ */
140
+ export function hasInFlightWrite(path) {
141
+ const q = queues.get(path);
142
+ return Boolean(q && q.inFlight);
143
+ }
144
+
145
+ try {
146
+ process.on('exit', drainAllSync);
147
+ } catch {
148
+ // Ignore lifecycle hook failures in embedded runtimes.
149
+ }
@@ -0,0 +1,98 @@
1
+ // Shared production of background-task notification envelopes.
2
+ //
3
+ // Producers (shell-jobs fire()/prompt-stall) and consumers (session
4
+ // ingest/manager detection, TUI parsers reading stored sessions) must agree
5
+ // byte-for-byte on the emitted wire format: bracket header fields, the blank
6
+ // line body separator, and the completion instruction wording. To keep one
7
+ // source of truth, the render helpers and the detection regexes live here.
8
+
9
+ import {
10
+ toolCompletionInstruction,
11
+ isInternalRuntimeNotificationText,
12
+ isBracketedShellNotificationEnvelope,
13
+ backgroundTaskHeaderStatus,
14
+ } from './tool-execution-contract.mjs';
15
+
16
+ // Re-export the envelope *detection* predicate so producers and consumers can
17
+ // pull render + detect from one module. Detection primitives live in
18
+ // tool-execution-contract.mjs (shouldPersistModelVisibleToolCompletion et al.
19
+ // depend on them there); this keeps a single import surface without forking
20
+ // the regexes.
21
+ export {
22
+ isInternalRuntimeNotificationText,
23
+ isBracketedShellNotificationEnvelope,
24
+ backgroundTaskHeaderStatus,
25
+ };
26
+
27
+ // Render the bracketed shell *completion* envelope body.
28
+ // Byte-compatible with the historical inline assembly in shell-jobs.fire().
29
+ export function renderShellCompletionEnvelope({
30
+ jobId,
31
+ status,
32
+ exitCode = null,
33
+ elapsedMs = null,
34
+ command = null,
35
+ summary = null,
36
+ stdoutPreview = null,
37
+ stderrPreview = null,
38
+ mergeStderr = false,
39
+ } = {}) {
40
+ const header = [
41
+ `[task_id: ${jobId}]`,
42
+ `[status: ${status}]`,
43
+ `[exit: ${exitCode === null ? 'n/a' : exitCode}]`,
44
+ elapsedMs !== null ? `[elapsed: ${elapsedMs} ms]` : null,
45
+ command ? `[command: ${command}]` : null,
46
+ ].filter((l) => l !== null);
47
+ const bodySections = [
48
+ summary ? `Summary: ${summary}` : null,
49
+ stdoutPreview ? `\n[stdout preview]\n${stdoutPreview}` : null,
50
+ (mergeStderr !== true && stderrPreview) ? `\n[stderr preview]\n${stderrPreview}` : null,
51
+ ].filter((l) => l !== null);
52
+ // Exactly one blank line separates the bracket header block from the body
53
+ // when any body section exists; no trailing blank line when there is none.
54
+ // (Previously the '' separator was filtered out by the value filter, so a
55
+ // summary-only envelope glued headers straight onto `Summary:` and read as
56
+ // bodyless to the `\n\s*\n` body detector.)
57
+ const lines = bodySections.length > 0
58
+ ? [...header, '', ...bodySections]
59
+ : header;
60
+ return lines.join('\n');
61
+ }
62
+
63
+ // Build the shell completion instruction via the shared wording so all async
64
+ // surfaces read identically ("The async shell task … has finished …"). The
65
+ // exit detail is folded into the shared detail slot.
66
+ export function shellCompletionInstruction({ jobId, status, exitCode = null } = {}) {
67
+ return toolCompletionInstruction({
68
+ surface: 'shell',
69
+ id: jobId,
70
+ status,
71
+ detail: `exit ${exitCode === null ? 'n/a' : exitCode}`,
72
+ });
73
+ }
74
+
75
+ // Render the bracketed shell *prompt-stall* progress envelope body.
76
+ // Byte-compatible with the historical inline assembly in maybeNotifyPromptStall.
77
+ export function renderShellPromptStallEnvelope({
78
+ jobId,
79
+ stalledMs,
80
+ elapsedMs = null,
81
+ command = null,
82
+ tailText = null,
83
+ } = {}) {
84
+ return [
85
+ `[task_id: ${jobId}]`,
86
+ '[status: running]',
87
+ `[stalled: no output growth for ${stalledMs} ms]`,
88
+ (elapsedMs !== null && elapsedMs >= 0) ? `[elapsed: ${elapsedMs} ms]` : null,
89
+ command ? `[command: ${command}]` : null,
90
+ '',
91
+ 'This background shell task appears to be waiting for interactive input. Background tasks cannot answer prompts automatically; cancel it or rerun with non-interactive flags/input.',
92
+ tailText ? `\n${tailText}` : null,
93
+ ].filter((line) => line !== null && line !== '').join('\n');
94
+ }
95
+
96
+ export function shellPromptStallInstruction({ jobId } = {}) {
97
+ return `The background shell task ${jobId} appears to be waiting for interactive input; inspect the prompt, then cancel or rerun it non-interactively.`;
98
+ }
@@ -0,0 +1,107 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ renderShellCompletionEnvelope,
5
+ shellCompletionInstruction,
6
+ renderShellPromptStallEnvelope,
7
+ shellPromptStallInstruction,
8
+ isInternalRuntimeNotificationText,
9
+ isBracketedShellNotificationEnvelope,
10
+ } from './task-notification-envelope.mjs';
11
+
12
+ test('completion envelope matches historical assembly plus the corrected blank-line separator', () => {
13
+ const jobId = 'shell_job_1';
14
+ const status = 'completed';
15
+ const exitCode = 0;
16
+ const elapsedMs = 1234;
17
+ const detail = {
18
+ command: 'npm test',
19
+ summary: 'ok',
20
+ stdoutPreview: 'out',
21
+ stderrPreview: 'err',
22
+ mergeStderr: false,
23
+ };
24
+ const expected = [
25
+ `[task_id: ${jobId}]`,
26
+ `[status: ${status}]`,
27
+ `[exit: ${exitCode === null ? 'n/a' : exitCode}]`,
28
+ elapsedMs !== null ? `[elapsed: ${elapsedMs} ms]` : null,
29
+ detail.command ? `[command: ${detail.command}]` : null,
30
+ // The historical inline assembly filtered this '' out, gluing `Summary:`
31
+ // onto the header block; the corrected renderer keeps exactly one blank
32
+ // line between the bracket headers and the body sections.
33
+ '',
34
+ detail.summary ? `Summary: ${detail.summary}` : null,
35
+ detail.stdoutPreview ? `\n[stdout preview]\n${detail.stdoutPreview}` : null,
36
+ (detail.mergeStderr !== true && detail.stderrPreview) ? `\n[stderr preview]\n${detail.stderrPreview}` : null,
37
+ ].filter((l) => l !== null).join('\n');
38
+ const actual = renderShellCompletionEnvelope({ jobId, status, exitCode, elapsedMs, ...detail });
39
+ assert.equal(actual, expected);
40
+ assert.equal(isInternalRuntimeNotificationText(actual), true);
41
+ assert.equal(isBracketedShellNotificationEnvelope(actual), true);
42
+ });
43
+
44
+ test('summary-only completion envelope keeps one blank line before the body', () => {
45
+ const actual = renderShellCompletionEnvelope({
46
+ jobId: 'shell_job_1',
47
+ status: 'completed',
48
+ exitCode: 0,
49
+ summary: 'ok',
50
+ });
51
+ const expected = [
52
+ '[task_id: shell_job_1]',
53
+ '[status: completed]',
54
+ '[exit: 0]',
55
+ '',
56
+ 'Summary: ok',
57
+ ].join('\n');
58
+ assert.equal(actual, expected);
59
+ // The blank-line separator must survive so body detectors see a body.
60
+ assert.match(actual, /\n\s*\n/);
61
+ });
62
+
63
+ test('bodyless completion envelope has no trailing blank line', () => {
64
+ const actual = renderShellCompletionEnvelope({
65
+ jobId: 'shell_job_1',
66
+ status: 'completed',
67
+ exitCode: 0,
68
+ });
69
+ assert.equal(actual, ['[task_id: shell_job_1]', '[status: completed]', '[exit: 0]'].join('\n'));
70
+ assert.doesNotMatch(actual, /\n\s*\n/);
71
+ });
72
+
73
+ test('completion instruction uses shared async wording + exit detail', () => {
74
+ assert.equal(
75
+ shellCompletionInstruction({ jobId: 'shell_job_1', status: 'completed', exitCode: 0 }),
76
+ 'The async shell task shell_job_1 has finished (completed, exit 0) - review this result in your next step.',
77
+ );
78
+ assert.match(shellCompletionInstruction({ jobId: 'x', status: 'failed', exitCode: null }), /exit n\/a/);
79
+ });
80
+
81
+ test('prompt-stall envelope is byte-compatible with historical inline assembly', () => {
82
+ const jobId = 'shell_job_1';
83
+ const stalledMs = 5000;
84
+ const elapsedMs = 6000;
85
+ const tail = { text: 'Password:' };
86
+ const detail = { command: 'ssh host' };
87
+ const expected = [
88
+ `[task_id: ${jobId}]`,
89
+ '[status: running]',
90
+ `[stalled: no output growth for ${stalledMs} ms]`,
91
+ elapsedMs >= 0 ? `[elapsed: ${elapsedMs} ms]` : null,
92
+ detail.command ? `[command: ${detail.command}]` : null,
93
+ '',
94
+ 'This background shell task appears to be waiting for interactive input. Background tasks cannot answer prompts automatically; cancel it or rerun with non-interactive flags/input.',
95
+ tail.text ? `\n${tail.text}` : null,
96
+ ].filter((line) => line !== null && line !== '').join('\n');
97
+ const actual = renderShellPromptStallEnvelope({ jobId, stalledMs, elapsedMs, command: detail.command, tailText: tail.text });
98
+ assert.equal(actual, expected);
99
+ assert.equal(isInternalRuntimeNotificationText(actual), true);
100
+ });
101
+
102
+ test('prompt-stall instruction wording unchanged', () => {
103
+ assert.equal(
104
+ shellPromptStallInstruction({ jobId: 'shell_job_1' }),
105
+ 'The background shell task shell_job_1 appears to be waiting for interactive input; inspect the prompt, then cancel or rerun it non-interactively.',
106
+ );
107
+ });
@@ -33,7 +33,7 @@ function notificationResultBody(text) {
33
33
  return match ? String(match[1] || '').trim() : '';
34
34
  }
35
35
 
36
- function backgroundTaskHeaderStatus(text) {
36
+ export function backgroundTaskHeaderStatus(text) {
37
37
  const match = /^status:\s*(\S+)/mi.exec(String(text || ''));
38
38
  return clean(match?.[1]).toLowerCase();
39
39
  }
@@ -86,7 +86,7 @@ export function shouldPersistModelVisibleToolCompletion(text, meta = {}) {
86
86
 
87
87
  const BRACKETED_SHELL_STATUS_RE = /^\[status:\s*(?:running|pending|queued|completed|failed|cancelled|canceled|error|timeout|done|success)\]/im;
88
88
 
89
- function isBracketedShellNotificationEnvelope(text) {
89
+ export function isBracketedShellNotificationEnvelope(text) {
90
90
  const value = String(text ?? '').trim();
91
91
  if (!value) return false;
92
92
  if (!/^\[task_id:\s*\S+\]/im.test(value)) return false;