@namzu/sdk 62.10.0 → 62.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +1 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +9 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/compaction/loaded-skills.d.ts +77 -0
  9. package/dist/compaction/loaded-skills.d.ts.map +1 -0
  10. package/dist/compaction/loaded-skills.js +203 -0
  11. package/dist/compaction/loaded-skills.js.map +1 -0
  12. package/dist/compaction/manager.d.ts +10 -0
  13. package/dist/compaction/manager.d.ts.map +1 -1
  14. package/dist/compaction/manager.js +20 -0
  15. package/dist/compaction/manager.js.map +1 -1
  16. package/dist/compaction/manual.d.ts.map +1 -1
  17. package/dist/compaction/manual.js +17 -3
  18. package/dist/compaction/manual.js.map +1 -1
  19. package/dist/compaction/restore.d.ts +5 -0
  20. package/dist/compaction/restore.d.ts.map +1 -1
  21. package/dist/compaction/restore.js +18 -18
  22. package/dist/compaction/restore.js.map +1 -1
  23. package/dist/contracts/session/api.d.ts +1 -1
  24. package/dist/contracts/session/api.d.ts.map +1 -1
  25. package/dist/execution/workflow/phases.d.ts +5 -2
  26. package/dist/execution/workflow/phases.d.ts.map +1 -1
  27. package/dist/execution/workflow/phases.js +32 -8
  28. package/dist/execution/workflow/phases.js.map +1 -1
  29. package/dist/runtime/query/iteration/phases/compaction.d.ts +1 -1
  30. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  31. package/dist/runtime/query/iteration/phases/compaction.js +76 -11
  32. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  33. package/dist/tools/builtins/skill.d.ts.map +1 -1
  34. package/dist/tools/builtins/skill.js +5 -0
  35. package/dist/tools/builtins/skill.js.map +1 -1
  36. package/dist/turn/reporter.d.ts.map +1 -1
  37. package/dist/turn/reporter.js +9 -0
  38. package/dist/turn/reporter.js.map +1 -1
  39. package/dist/types/session/events.d.ts +26 -3
  40. package/dist/types/session/events.d.ts.map +1 -1
  41. package/dist/types/session/events.js +4 -1
  42. package/dist/types/session/events.js.map +1 -1
  43. package/dist/types/session/records.d.ts.map +1 -1
  44. package/dist/types/session/records.js +1 -0
  45. package/dist/types/session/records.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/bridge/a2a/mapper.ts +1 -0
  48. package/src/bridge/sse/mapper.ts +10 -0
  49. package/src/compaction/loaded-skills.ts +245 -0
  50. package/src/compaction/manager.ts +24 -0
  51. package/src/compaction/manual.ts +20 -2
  52. package/src/compaction/restore.ts +33 -18
  53. package/src/contracts/session/api.ts +1 -0
  54. package/src/execution/workflow/phases.ts +34 -8
  55. package/src/runtime/query/iteration/phases/compaction.ts +102 -16
  56. package/src/tools/builtins/skill.ts +5 -0
  57. package/src/turn/reporter.ts +10 -0
  58. package/src/types/session/events.ts +27 -2
  59. package/src/types/session/records.ts +1 -0
@@ -12,25 +12,51 @@ export interface PhaseRecord {
12
12
  readonly declared: boolean
13
13
  }
14
14
 
15
+ /**
16
+ * The entry of `entries` a title belongs to: one with exactly that title
17
+ * first; only when there is none, the first one whose title matches ignoring
18
+ * case. Two entries that differ only by case therefore stay two, because each
19
+ * matches itself exactly.
20
+ */
21
+ function matchTitle<T extends { readonly title: string }>(
22
+ entries: Iterable<T>,
23
+ title: string,
24
+ ): T | undefined {
25
+ const lower = title.toLowerCase()
26
+ let loose: T | undefined
27
+ for (const entry of entries) {
28
+ if (entry.title === title) return entry
29
+ if (loose === undefined && entry.title.toLowerCase() === lower) loose = entry
30
+ }
31
+ return loose
32
+ }
33
+
15
34
  /**
16
35
  * Phase titles of one run. A title is the only grouping key: the same title
17
- * is the same group. Indexes are 1-based first-seen order, declared titles
36
+ * is the same group. A title matches a registered one exactly first; only when
37
+ * none matches exactly does it match one ignoring case (the first registered
38
+ * wins, and its spelling is kept). Two declared titles that differ only by
39
+ * case stay two phases. Indexes are 1-based first-seen order, declared titles
18
40
  * first. `register` reports whether the title was announced just now.
19
41
  */
20
42
  export class PhaseRegistry {
21
- private readonly byTitle = new Map<string, PhaseRecord>()
43
+ private readonly records: PhaseRecord[] = []
22
44
 
23
45
  register(rawTitle: string, declared = false): { phase: PhaseRecord; isNew: boolean } {
24
46
  const title = normalizePhaseTitle(rawTitle)
25
- const existing = this.byTitle.get(title)
47
+ // A declared title is matched exactly only, so two declared titles that
48
+ // differ only by case each get their own phase.
49
+ const existing = declared
50
+ ? this.records.find((record) => record.title === title)
51
+ : matchTitle(this.records, title)
26
52
  if (existing) return { phase: existing, isNew: false }
27
- const phase = { index: this.byTitle.size + 1, title, declared }
28
- this.byTitle.set(title, phase)
53
+ const phase = { index: this.records.length + 1, title, declared }
54
+ this.records.push(phase)
29
55
  return { phase, isNew: true }
30
56
  }
31
57
 
32
58
  all(): PhaseRecord[] {
33
- return [...this.byTitle.values()]
59
+ return [...this.records]
34
60
  }
35
61
  }
36
62
 
@@ -57,7 +83,7 @@ export function summarizeWorkflowPhases(
57
83
  phases: readonly PhaseRecord[],
58
84
  agents: readonly PhaseAgentView[],
59
85
  ): WorkflowPhaseSummary[] {
60
- const detail = new Map(declared.map((p) => [normalizePhaseTitle(p.title), p.detail]))
86
+ const named = declared.map((p) => ({ title: normalizePhaseTitle(p.title), detail: p.detail }))
61
87
  const summarize = (
62
88
  index: number,
63
89
  title: string,
@@ -82,7 +108,7 @@ export function summarizeWorkflowPhases(
82
108
  p.index,
83
109
  p.title,
84
110
  agents.filter((a) => a.phaseIndex === p.index),
85
- detail.get(p.title),
111
+ matchTitle(named, p.title)?.detail,
86
112
  ),
87
113
  )
88
114
  const ungrouped = agents.filter((a) => a.phaseIndex === undefined)
@@ -3,6 +3,13 @@ import {
3
3
  resolveTriggerThreshold,
4
4
  } from '../../../../compaction/context-window.js'
5
5
  import { findDanglingMessages } from '../../../../compaction/dangling.js'
6
+ import {
7
+ LOADED_SKILLS_ENTRY_KEY,
8
+ type RenderedSkills,
9
+ adoptableSkills,
10
+ handledSkillNames,
11
+ renderLoadedSkills,
12
+ } from '../../../../compaction/loaded-skills.js'
6
13
  import { replaceableSummaries, summarizeWithModel } from '../../../../compaction/model-summary.js'
7
14
  import {
8
15
  type CompactionPlan,
@@ -29,7 +36,11 @@ import { CHARS_PER_TOKEN } from '../../../../constants/limits.js'
29
36
  import { NAMZU } from '../../../../constants/telemetry/index.js'
30
37
  import { invariants } from '../../../../invariants/index.js'
31
38
  import { resolveTaskModel } from '../../../../model-router/task-router.js'
32
- import type { Message } from '../../../../types/message/index.js'
39
+ import {
40
+ type Message,
41
+ createTurnContextEntry,
42
+ turnContextEntries,
43
+ } from '../../../../types/message/index.js'
33
44
  import { applyLifecycleHookResults } from '../../plugin-hooks.js'
34
45
  import {
35
46
  COMPACTION_REFILL_MESSAGE,
@@ -168,6 +179,65 @@ async function emitToolResultClear(
168
179
  })
169
180
  }
170
181
 
182
+ /**
183
+ * Keep the skills this conversation loaded where the pass cannot cut them.
184
+ * Called before a pass edits the history: whatever the messages show of a
185
+ * skill now goes into the working state, which outlives the messages.
186
+ */
187
+ function adoptLoadedSkills(ctx: IterationContext): void {
188
+ ctx.workingStateManager?.adoptSkillBodies(adoptableSkills(ctx.recorder.messages))
189
+ }
190
+
191
+ /**
192
+ * After a pass that cut or cleared history: if a loaded skill's instructions
193
+ * are no longer in it, say them again, as reference.
194
+ *
195
+ * The entry goes at the END of the history, after everything the pass left.
196
+ * A clear already rewrites the request from the first result it cut, so the
197
+ * cached start of the request ends no earlier than it did; putting the skills
198
+ * anywhere before that point would move it further forward. At the end it
199
+ * also reads as the latest guidance, and the entry is never edited after, so
200
+ * later requests start with it unchanged.
201
+ */
202
+ async function carryLoadedSkills(
203
+ ctx: IterationContext,
204
+ contextWindowTokens: number,
205
+ ): Promise<void> {
206
+ const manager = ctx.workingStateManager
207
+ const loaded = manager?.getState().restorable?.skills
208
+ if (!loaded || loaded.length === 0) return
209
+ const handled = handledSkillNames(ctx.recorder.messages)
210
+ const missing = loaded.filter((skill) => !handled.has(skill.name))
211
+ if (missing.length === 0) return
212
+ const rendered = renderLoadedSkills(missing, { contextWindowTokens })
213
+ if (!rendered.text) return
214
+ const earlier = turnContextEntries(ctx.recorder.messages, LOADED_SKILLS_ENTRY_KEY)
215
+ ctx.recorder.pushMessage(
216
+ createTurnContextEntry(LOADED_SKILLS_ENTRY_KEY, rendered.text, new Date(), {
217
+ later: earlier.length > 0,
218
+ }),
219
+ )
220
+ await emitSkillsCarried(ctx, rendered)
221
+ }
222
+
223
+ async function emitSkillsCarried(
224
+ ctx: IterationContext,
225
+ rendered: {
226
+ carried: readonly string[]
227
+ cut: readonly string[]
228
+ left: readonly string[]
229
+ },
230
+ ): Promise<void> {
231
+ await ctx.emitEvent?.({
232
+ type: 'compaction_skills_carried',
233
+ turnId: ctx.recorder.turnId,
234
+ iteration: ctx.recorder.currentIteration,
235
+ skills: [...rendered.carried],
236
+ cut: [...rendered.cut],
237
+ leftOut: [...rendered.left],
238
+ })
239
+ }
240
+
171
241
  /** Install a clear-only pass and publish the state it established. */
172
242
  async function commitToolResultClear(
173
243
  ctx: IterationContext,
@@ -180,11 +250,13 @@ async function commitToolResultClear(
180
250
  // content in the same archive used by whole-message compaction before
181
251
  // changing the live history or publishing the clear.
182
252
  await recordShed(ctx, [...ctx.recorder.messages], plan.messages, reason)
253
+ adoptLoadedSkills(ctx)
183
254
  installMessages(ctx.recorder.messages, plan.messages)
184
255
  // The provider counted the pre-edit prompt. Leaving that measurement live
185
256
  // makes the next trigger compare the old prompt against the new history.
186
257
  ctx.recorder.clearLastPromptTokens?.()
187
258
  await emitToolResultClear(ctx, plan)
259
+ await carryLoadedSkills(ctx, contextWindowTokens)
188
260
  await emitContextUsageSnapshot(ctx, estimateTokens(ctx), contextWindowTokens, windowSource)
189
261
  // A host-requested pass is not the kernel's own relief of a full context,
190
262
  // so it must not reset the breaker's failure streak.
@@ -1136,6 +1208,9 @@ async function runCompactionCheckInner(
1136
1208
 
1137
1209
  // Skills the model loaded and files it read go back verbatim after the
1138
1210
  // summary, with whatever the host wants carried across (its plan file).
1211
+ // The skills come from the conversation as it stands, so a skill loaded in
1212
+ // an earlier turn, or before a resume, is put back too.
1213
+ adoptLoadedSkills(ctx)
1139
1214
  let hostNote: string | undefined
1140
1215
  try {
1141
1216
  hostNote = await ctx.postCompactionContext?.()
@@ -1146,21 +1221,6 @@ async function runCompactionCheckInner(
1146
1221
  'exception.message': error instanceof Error ? error.message : String(error),
1147
1222
  })
1148
1223
  }
1149
- const restored = renderRestoredContext(manager.getState(), {
1150
- contextWindowTokens: budget,
1151
- ...(hostNote ? { hostNote } : {}),
1152
- })
1153
- if (restored) compactedContent = `${compactedContent}\n\n${restored}`
1154
-
1155
- // The pointer is only worth a line when both tools it names are mounted.
1156
- const mounted = new Set(ctx.tools?.listNames?.() ?? [])
1157
- const compactionMessage = buildCompactionMessage(
1158
- compactedContent,
1159
- mounted.has('search_conversation') && mounted.has('read_conversation')
1160
- ? COMPACTION_RECOVERY_NOTE
1161
- : undefined,
1162
- )
1163
-
1164
1224
  // Drop a replaceable PRIOR `[COMPACTED CONTEXT]` summary from this turn's
1165
1225
  // leading floor — `serializeState` is cumulative, so the new summary
1166
1226
  // supersedes it. A retained summary came from outside this manager's state
@@ -1183,6 +1243,29 @@ async function runCompactionCheckInner(
1183
1243
  .slice(systemMessages.length, keepStart)
1184
1244
  .filter((_, offset) => retained.has(systemMessages.length + offset))
1185
1245
 
1246
+ // A skill whose instructions are in what the pass keeps as it is, whole or
1247
+ // shown by an earlier pass, is not put back a second time.
1248
+ const wholeSkills = handledSkillNames([...preservedSystem, ...retainedOlder, ...recentMessages])
1249
+ const putBack: { rendered?: RenderedSkills } = {}
1250
+ const restored = renderRestoredContext(manager.getState(), {
1251
+ contextWindowTokens: budget,
1252
+ wholeSkills,
1253
+ onSkills: (rendered) => {
1254
+ putBack.rendered = rendered
1255
+ },
1256
+ ...(hostNote ? { hostNote } : {}),
1257
+ })
1258
+ if (restored) compactedContent = `${compactedContent}\n\n${restored}`
1259
+
1260
+ // The pointer is only worth a line when both tools it names are mounted.
1261
+ const mounted = new Set(ctx.tools?.listNames?.() ?? [])
1262
+ const compactionMessage = buildCompactionMessage(
1263
+ compactedContent,
1264
+ mounted.has('search_conversation') && mounted.has('read_conversation')
1265
+ ? COMPACTION_RECOVERY_NOTE
1266
+ : undefined,
1267
+ )
1268
+
1186
1269
  const newMessages = [...preservedSystem, compactionMessage, ...retainedOlder, ...recentMessages]
1187
1270
 
1188
1271
  // OPAQUE survival guard: the pinned working-memory slot is a
@@ -1282,6 +1365,9 @@ async function runCompactionCheckInner(
1282
1365
  reachedResetThreshold: reachedReset,
1283
1366
  })
1284
1367
  await emitContextUsageSnapshot(ctx, newEstimate, budget, window.source)
1368
+ if (putBack.rendered && putBack.rendered.carried.length > 0) {
1369
+ await emitSkillsCarried(ctx, putBack.rendered)
1370
+ }
1285
1371
  compactionBreaker(ctx)?.recordCompaction(
1286
1372
  ctx.recorder.currentIteration,
1287
1373
  beforeTokens,
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
2
2
  import { z } from 'zod'
3
3
 
4
4
  import { parseAllowedTools } from '../../authorization/skill-grant.js'
5
+ import { forkedSkillReportNote } from '../../compaction/loaded-skills.js'
5
6
  import type { Sandbox } from '../../types/sandbox/index.js'
6
7
  import { isInvocableBy, skillInvocation } from '../../types/skills/index.js'
7
8
  import type { ToolContext } from '../../types/tool/index.js'
@@ -701,6 +702,10 @@ async function runForkedSkill(input: {
701
702
  })
702
703
  return {
703
704
  ...result,
705
+ // Says what the text is, so a later pass never carries a report as the skill's steps.
706
+ output: result.success
707
+ ? `${forkedSkillReportNote(input.name)}\n\n${result.output}`
708
+ : result.output,
704
709
  data: {
705
710
  ...(typeof result.data === 'object' && result.data !== null ? result.data : {}),
706
711
  skill: input.name,
@@ -350,6 +350,16 @@ export function createTurnReporter(parentLogger?: Logger): TurnReporter {
350
350
  })
351
351
  break
352
352
 
353
+ case 'compaction_skills_carried':
354
+ log.info('Put loaded skills back after a pass cut them', {
355
+ [NAMZU.TURN_ID]: event.turnId,
356
+ [NAMZU.ITERATION]: event.iteration,
357
+ 'namzu.compaction.skills': event.skills.join(','),
358
+ 'namzu.compaction.skills_cut': event.cut.join(','),
359
+ 'namzu.compaction.skills_left_out': event.leftOut.join(','),
360
+ })
361
+ break
362
+
353
363
  case 'compaction_failed':
354
364
  // warn rather than info: the turn is now continuing at a context
355
365
  // size it had already decided was too large.
@@ -304,6 +304,27 @@ type CoreSessionEvent =
304
304
  */
305
305
  reliefWasEnough: boolean
306
306
  }
307
+ /**
308
+ * A pass cut or summarised history and the instructions of skills the
309
+ * conversation had loaded were put back in front of the model, as reference.
310
+ *
311
+ * Emitted only when something was put back: a skill whose instructions are
312
+ * still whole in the context is never repeated, so most passes say nothing.
313
+ * Live only (ephemeral): it is not written to the session log, because the
314
+ * put-back text is a message of its own and is in the log as one.
315
+ */
316
+ | {
317
+ type: 'compaction_skills_carried'
318
+ sessionId: SessionId
319
+ turnId?: TurnId
320
+ iteration: number
321
+ /** Skills put back, oldest load first. */
322
+ skills: string[]
323
+ /** The ones among them cut to their per-skill cap, each with a line saying how to load it in full. */
324
+ cut: string[]
325
+ /** Skills left out for room, newest first; each is named in the text the model reads. */
326
+ leftOut: string[]
327
+ }
307
328
  /**
308
329
  * A compaction pass ran and shed nothing, so the history is unchanged.
309
330
  *
@@ -1443,7 +1464,9 @@ export type SessionEventListener = (event: SessionEvent) => void | Promise<void>
1443
1464
  * log is what cannot be written while they matter.
1444
1465
  * `compaction_started` is ephemeral too: it only says a pass is about to run,
1445
1466
  * and the `compaction` record or `compaction_failed` that ends it is the
1446
- * durable fact.
1467
+ * durable fact. `compaction_skills_carried` is ephemeral for the same kind of
1468
+ * reason: the text it announces is a message of its own and is in the log as
1469
+ * one, so a log carries no record an older reader would have to skip.
1447
1470
  */
1448
1471
  export const EPHEMERAL_EVENT_TYPES: ReadonlySet<SessionEventType> = new Set<SessionEventType>([
1449
1472
  'text_delta',
@@ -1452,6 +1475,7 @@ export const EPHEMERAL_EVENT_TYPES: ReadonlySet<SessionEventType> = new Set<Sess
1452
1475
  'tool_progress',
1453
1476
  'monitor_event',
1454
1477
  'compaction_started',
1478
+ 'compaction_skills_carried',
1455
1479
  'session_log_degraded',
1456
1480
  'session_log_recovered',
1457
1481
  ])
@@ -1460,7 +1484,7 @@ export function isEphemeralEvent(event: { readonly type: string }): boolean {
1460
1484
  return EPHEMERAL_EVENT_TYPES.has(event.type as SessionEventType)
1461
1485
  }
1462
1486
 
1463
- /** The event types a session log records: every literal except the ephemeral eight. */
1487
+ /** The event types a session log records: every literal except the ephemeral nine. */
1464
1488
  export type PersistedSessionEventType = Exclude<
1465
1489
  SessionEventType,
1466
1490
  | 'text_delta'
@@ -1469,6 +1493,7 @@ export type PersistedSessionEventType = Exclude<
1469
1493
  | 'tool_progress'
1470
1494
  | 'monitor_event'
1471
1495
  | 'compaction_started'
1496
+ | 'compaction_skills_carried'
1472
1497
  | 'session_log_degraded'
1473
1498
  | 'session_log_recovered'
1474
1499
  >
@@ -724,6 +724,7 @@ const SESSION_EVENT_TYPE_TABLE: Record<SessionEventType, true> = {
724
724
  compaction_shed: true,
725
725
  compaction_completed: true,
726
726
  compaction_tool_results_cleared: true,
727
+ compaction_skills_carried: true,
727
728
  compaction_failed: true,
728
729
  tool_executing: true,
729
730
  tool_progress: true,