@nathapp/nax-agent 0.91.7 → 0.91.8-canary.2

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 (87) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/config/catalog-overrides.d.ts +13 -0
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +1 -0
  5. package/dist/internal/atomic-write.d.ts +22 -7
  6. package/dist/internal/atomic-write.js +107 -12
  7. package/dist/internal/pinned-directory-worker.d.ts +52 -0
  8. package/dist/internal/pinned-directory-worker.js +105 -0
  9. package/dist/internal/pinned-directory.d.ts +48 -0
  10. package/dist/internal/pinned-directory.js +168 -0
  11. package/dist/internal.d.ts +2 -0
  12. package/dist/internal.js +2 -0
  13. package/dist/memory/delivery.d.ts +38 -0
  14. package/dist/memory/delivery.js +28 -0
  15. package/dist/memory/index-render.d.ts +55 -0
  16. package/dist/memory/index-render.js +225 -0
  17. package/dist/memory/index.d.ts +12 -0
  18. package/dist/memory/index.js +11 -0
  19. package/dist/memory/local-provider.d.ts +3 -0
  20. package/dist/memory/local-provider.js +127 -0
  21. package/dist/memory/mirror-lock.d.ts +109 -0
  22. package/dist/memory/mirror-lock.js +334 -0
  23. package/dist/memory/note.d.ts +15 -0
  24. package/dist/memory/note.js +131 -0
  25. package/dist/memory/operations.d.ts +123 -0
  26. package/dist/memory/operations.js +471 -0
  27. package/dist/memory/outbox.d.ts +35 -0
  28. package/dist/memory/outbox.js +289 -0
  29. package/dist/memory/provider-types.d.ts +50 -0
  30. package/dist/memory/provider-types.js +1 -0
  31. package/dist/memory/records.d.ts +41 -0
  32. package/dist/memory/records.js +93 -0
  33. package/dist/memory/refusal.d.ts +36 -0
  34. package/dist/memory/refusal.js +25 -0
  35. package/dist/memory/search.d.ts +17 -0
  36. package/dist/memory/search.js +112 -0
  37. package/dist/memory/store.d.ts +106 -0
  38. package/dist/memory/store.js +459 -0
  39. package/dist/memory/support.d.ts +63 -0
  40. package/dist/memory/support.js +98 -0
  41. package/dist/memory/sync-flight.d.ts +25 -0
  42. package/dist/memory/sync-flight.js +65 -0
  43. package/dist/memory/sync.d.ts +82 -0
  44. package/dist/memory/sync.js +344 -0
  45. package/dist/memory/types.d.ts +108 -0
  46. package/dist/memory/types.js +51 -0
  47. package/dist/memory/validate.d.ts +31 -0
  48. package/dist/memory/validate.js +104 -0
  49. package/dist/native/adapter-deps.d.ts +9 -0
  50. package/dist/native/adapter-deps.js +9 -0
  51. package/dist/native/models.js +3 -0
  52. package/dist/native/session/compaction.d.ts +15 -1
  53. package/dist/native/session/compaction.js +90 -23
  54. package/dist/native/session/manual-compaction.d.ts +57 -0
  55. package/dist/native/session/manual-compaction.js +72 -0
  56. package/dist/native/session/session-summarizer.d.ts +75 -0
  57. package/dist/native/session/session-summarizer.js +73 -0
  58. package/dist/native/session/transcript-identity.d.ts +12 -0
  59. package/dist/native/session/transcript-identity.js +16 -0
  60. package/dist/native/session/turn-compaction-step.d.ts +30 -2
  61. package/dist/native/session/turn-compaction-step.js +7 -1
  62. package/dist/native/session/turn-loop-round-trip.js +71 -17
  63. package/dist/native/session/turn-loop.js +2 -6
  64. package/dist/native/session-adapter.d.ts +19 -0
  65. package/dist/native/session-adapter.js +42 -64
  66. package/dist/sandbox/srt-backend.js +9 -16
  67. package/dist/session/agent-session-options.js +1 -0
  68. package/dist/session/agent-session-turn.d.ts +6 -0
  69. package/dist/session/agent-session-turn.js +5 -4
  70. package/dist/session/agent-session-types.d.ts +38 -0
  71. package/dist/session/agent-session.d.ts +8 -0
  72. package/dist/session/agent-session.js +163 -20
  73. package/dist/session/native-backend.js +8 -0
  74. package/dist/session/session-backend.d.ts +19 -1
  75. package/dist/session/session-types.d.ts +6 -0
  76. package/dist/session/steer-channel.d.ts +36 -0
  77. package/dist/session/steer-channel.js +39 -0
  78. package/dist/session-store/index.d.ts +1 -1
  79. package/dist/session-store/index.js +1 -1
  80. package/dist/session-store/session-lock.d.ts +2 -2
  81. package/dist/session-store/session-lock.js +4 -2
  82. package/dist/tools/ask-request.d.ts +8 -0
  83. package/dist/tools/ask-request.js +34 -1
  84. package/dist/tools/provider-adapt.js +5 -1
  85. package/dist/tools/provider-types.d.ts +7 -0
  86. package/dist/tools/types.d.ts +16 -0
  87. package/package.json +4 -4
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The memory note model — the shapes and limits shared by the note codec, the
3
+ * mirror store, the index renderer, the providers and the sync loop.
4
+ *
5
+ * A note is one Markdown file with frontmatter (`note.ts` writes and reads it);
6
+ * a decision is one line of the mirror's read-only `decisions.jsonl`; a hit is
7
+ * one keyword-search result; a timers object is the race seam the sync loop
8
+ * injects in tests.
9
+ */
10
+ export declare const MEMORY_NOTE_TYPES: readonly ["user", "feedback", "project", "reference"];
11
+ export type MemoryNoteType = (typeof MEMORY_NOTE_TYPES)[number];
12
+ export declare const MEMORY_SCOPES: readonly ["project", "user"];
13
+ export type MemoryScope = (typeof MEMORY_SCOPES)[number];
14
+ export declare const MEMORY_NOTE_STATUSES: readonly ["active", "pending", "superseded", "rejected", "deleted"];
15
+ export type MemoryNoteStatus = (typeof MEMORY_NOTE_STATUSES)[number];
16
+ export declare const MEMORY_DECISION_STATUSES: readonly ["active", "superseded", "rejected"];
17
+ export type MemoryDecisionStatus = (typeof MEMORY_DECISION_STATUSES)[number];
18
+ export interface MemoryNote {
19
+ readonly id: string;
20
+ readonly slug: string;
21
+ readonly type: MemoryNoteType;
22
+ readonly scope: MemoryScope;
23
+ readonly description: string;
24
+ readonly source: string;
25
+ readonly updatedAt: string;
26
+ readonly status: MemoryNoteStatus;
27
+ readonly body: string;
28
+ }
29
+ export interface MemoryDecision {
30
+ readonly id: string;
31
+ readonly subject: string;
32
+ readonly predicate: string;
33
+ readonly object: string;
34
+ readonly updatedAt: string;
35
+ readonly status: MemoryDecisionStatus;
36
+ }
37
+ /**
38
+ * One queued forget: a `proposal` forget the mirror accepted but did not act on, kept in
39
+ * the mirror's `pending-forgets.jsonl` so it outlives the process that asked for it — the
40
+ * index marks it and an approval finds it later. The `clientId` is the write's idempotency
41
+ * key, and `reason` is the justification the forget carried (`""` when it carried none).
42
+ */
43
+ export interface MemoryPendingForget {
44
+ readonly clientId: string;
45
+ readonly scope: MemoryScope;
46
+ readonly slug: string;
47
+ readonly reason: string;
48
+ }
49
+ export interface MemoryHit {
50
+ readonly slug: string;
51
+ readonly scope: MemoryScope;
52
+ readonly type: MemoryNoteType;
53
+ readonly description: string;
54
+ readonly status: MemoryNoteStatus;
55
+ readonly score: number;
56
+ readonly snippet: string;
57
+ }
58
+ export interface MemoryNoteWrite {
59
+ readonly slug: string;
60
+ readonly type: MemoryNoteType;
61
+ readonly scope: MemoryScope;
62
+ readonly description: string;
63
+ readonly body: string;
64
+ }
65
+ export interface MemoryWriteRefusal {
66
+ readonly field: "slug" | "type" | "description" | "body" | "source" | "reason";
67
+ readonly reason: string;
68
+ }
69
+ export interface MemoryTimers {
70
+ setTimeout(callback: () => void, ms: number): unknown;
71
+ clearTimeout(id: unknown): void;
72
+ }
73
+ /** A note slug: 1-64 characters, lower-case alphanumerics and interior `-`. */
74
+ export declare const MEMORY_SLUG_RE: RegExp;
75
+ /** An outbox client id: 1-128 letters, digits, `_` or `-`. */
76
+ export declare const MEMORY_CLIENT_ID_RE: RegExp;
77
+ export declare const MEMORY_NOTE_BODY_MAX_BYTES = 8192;
78
+ export declare const MEMORY_DESCRIPTION_MAX_CHARS = 200;
79
+ export declare const MEMORY_SOURCE_MAX_CHARS = 64;
80
+ /**
81
+ * The longest justification a forget may carry.
82
+ *
83
+ * A reason is a one-line answer to "why did this note go", and it is persisted twice over: on
84
+ * the request the provider is pushed, and inside the outbox entry a forget that cannot be
85
+ * delivered leaves behind. Bounding it here — the same cap as a note's description, because it
86
+ * is the same kind of text — keeps a terminal or a step from filling the mirror's queue with a
87
+ * justification of its own making. `forgetReasonRefusal` is where the rule is applied to a
88
+ * forget, and the outbox applies that same rule when it takes a request, so the queue never
89
+ * holds one it cannot replay. An entry already on disk is read whatever its reason's length:
90
+ * the bound is a condition on writing, not on reading.
91
+ */
92
+ export declare const MEMORY_FORGET_REASON_MAX_CHARS = 200;
93
+ export declare const MEMORY_INDEX_MAX_BYTES = 24576;
94
+ export declare const MEMORY_INDEX_MAX_LINES = 200;
95
+ export declare const MEMORY_INDEX_MIN_LINES = 16;
96
+ export declare const MEMORY_INDEX_MIN_BYTES = 512;
97
+ export declare const MEMORY_PROBE_TIMEOUT_MS = 2000;
98
+ export declare const MEMORY_OP_TIMEOUT_MS = 10000;
99
+ export declare const MEMORY_PULL_PAGE_SIZE = 200;
100
+ export declare const MEMORY_PULL_MAX_PAGES = 50;
101
+ /**
102
+ * The key a note is addressed by wherever the mirror and the queue meet: `<scope>/<slug>`.
103
+ *
104
+ * The outbox holds writes by this key and a save asks the outbox whether the key it is
105
+ * about to write is already held, so the two spellings have to be one: a save that asks
106
+ * with a different key would never see the entry it must queue behind.
107
+ */
108
+ export declare function memoryNoteKey(scope: MemoryScope, slug: string): string;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The memory note model — the shapes and limits shared by the note codec, the
3
+ * mirror store, the index renderer, the providers and the sync loop.
4
+ *
5
+ * A note is one Markdown file with frontmatter (`note.ts` writes and reads it);
6
+ * a decision is one line of the mirror's read-only `decisions.jsonl`; a hit is
7
+ * one keyword-search result; a timers object is the race seam the sync loop
8
+ * injects in tests.
9
+ */
10
+ export const MEMORY_NOTE_TYPES = ["user", "feedback", "project", "reference"];
11
+ export const MEMORY_SCOPES = ["project", "user"];
12
+ export const MEMORY_NOTE_STATUSES = ["active", "pending", "superseded", "rejected", "deleted"];
13
+ export const MEMORY_DECISION_STATUSES = ["active", "superseded", "rejected"];
14
+ /** A note slug: 1-64 characters, lower-case alphanumerics and interior `-`. */
15
+ export const MEMORY_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
16
+ /** An outbox client id: 1-128 letters, digits, `_` or `-`. */
17
+ export const MEMORY_CLIENT_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
18
+ export const MEMORY_NOTE_BODY_MAX_BYTES = 8192;
19
+ export const MEMORY_DESCRIPTION_MAX_CHARS = 200;
20
+ export const MEMORY_SOURCE_MAX_CHARS = 64;
21
+ /**
22
+ * The longest justification a forget may carry.
23
+ *
24
+ * A reason is a one-line answer to "why did this note go", and it is persisted twice over: on
25
+ * the request the provider is pushed, and inside the outbox entry a forget that cannot be
26
+ * delivered leaves behind. Bounding it here — the same cap as a note's description, because it
27
+ * is the same kind of text — keeps a terminal or a step from filling the mirror's queue with a
28
+ * justification of its own making. `forgetReasonRefusal` is where the rule is applied to a
29
+ * forget, and the outbox applies that same rule when it takes a request, so the queue never
30
+ * holds one it cannot replay. An entry already on disk is read whatever its reason's length:
31
+ * the bound is a condition on writing, not on reading.
32
+ */
33
+ export const MEMORY_FORGET_REASON_MAX_CHARS = 200;
34
+ export const MEMORY_INDEX_MAX_BYTES = 24_576;
35
+ export const MEMORY_INDEX_MAX_LINES = 200;
36
+ export const MEMORY_INDEX_MIN_LINES = 16;
37
+ export const MEMORY_INDEX_MIN_BYTES = 512;
38
+ export const MEMORY_PROBE_TIMEOUT_MS = 2000;
39
+ export const MEMORY_OP_TIMEOUT_MS = 10_000;
40
+ export const MEMORY_PULL_PAGE_SIZE = 200;
41
+ export const MEMORY_PULL_MAX_PAGES = 50;
42
+ /**
43
+ * The key a note is addressed by wherever the mirror and the queue meet: `<scope>/<slug>`.
44
+ *
45
+ * The outbox holds writes by this key and a save asks the outbox whether the key it is
46
+ * about to write is already held, so the two spellings have to be one: a save that asks
47
+ * with a different key would never see the entry it must queue behind.
48
+ */
49
+ export function memoryNoteKey(scope, slug) {
50
+ return `${scope}/${slug}`;
51
+ }
@@ -0,0 +1,31 @@
1
+ import { type MemoryNoteWrite, type MemoryWriteRefusal } from "./types.ts";
2
+ /**
3
+ * The write as received. `type` stays `string` because the value arrives from
4
+ * an agent tool call: narrowing it in the type system would leave the runtime
5
+ * check unreachable without a cast in the very test that has to exercise it.
6
+ */
7
+ export type MemoryNoteWriteInput = Omit<MemoryNoteWrite, "type"> & {
8
+ readonly type: string;
9
+ };
10
+ /**
11
+ * The first secret-shaped run in `text`, as its pattern's `kind` label. Every
12
+ * pattern carries the `/g` flag (the logger needs it for `replace`), so
13
+ * `lastIndex` is reset before each scan — otherwise a second call would resume
14
+ * where the first stopped and skip a match.
15
+ */
16
+ export declare function findSecret(text: string): {
17
+ readonly kind: string;
18
+ } | null;
19
+ /**
20
+ * The refusal for a forget's reason, or `null` when it may be recorded.
21
+ *
22
+ * A reason is what a later session reads to know why a note went, so it is held to the same
23
+ * one-line, non-empty, length-capped rule as a note's description: it is persisted on the
24
+ * request and inside a queued entry, and neither of those is a place to keep an essay.
25
+ */
26
+ export declare function forgetReasonRefusal(reason: string): MemoryWriteRefusal | null;
27
+ /**
28
+ * The refusal for a note write, or `null` when it may be stored. Stops at the
29
+ * first problem, in the design's order, so the caller reports one reason.
30
+ */
31
+ export declare function validateMemoryNoteWrite(write: MemoryNoteWriteInput, source: string): MemoryWriteRefusal | null;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Write validation — the check a save runs before anything is persisted.
3
+ *
4
+ * `validateMemoryNoteWrite` answers one question for the save path
5
+ * (`operations.ts`): may this note be stored at all? It returns `null` when it
6
+ * may, or the first `MemoryWriteRefusal` it found, checking in the design's
7
+ * order: slug, type, description, source, body size, then secrets.
8
+ *
9
+ * `findSecret` is the secret half on its own: it runs the logger's
10
+ * `SECRET_VALUE_PATTERNS` over free text and reports the kind of the first
11
+ * accepted match. Both functions are pure — no file, no logger, no I/O — so the
12
+ * refusal text is all the caller has to work with, and it never contains the
13
+ * matched text.
14
+ */
15
+ import { SECRET_VALUE_PATTERNS } from "#src/internal/redact";
16
+ import { MEMORY_DESCRIPTION_MAX_CHARS, MEMORY_FORGET_REASON_MAX_CHARS, MEMORY_NOTE_BODY_MAX_BYTES, MEMORY_NOTE_TYPES, MEMORY_SLUG_RE, MEMORY_SOURCE_MAX_CHARS, } from "./types.js";
17
+ /** A one-line field may carry no line break and no ASCII control character (a tab or DEL included). */
18
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is the point of this check
19
+ const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f]/;
20
+ function isMemoryNoteType(value) {
21
+ return MEMORY_NOTE_TYPES.some((type) => type === value);
22
+ }
23
+ function slugRefusal(slug) {
24
+ if (MEMORY_SLUG_RE.test(slug))
25
+ return null;
26
+ return { field: "slug", reason: 'slug must be 1-64 lower-case letters, digits or "-"' };
27
+ }
28
+ function typeRefusal(type) {
29
+ if (isMemoryNoteType(type))
30
+ return null;
31
+ return { field: "type", reason: `type must be one of ${MEMORY_NOTE_TYPES.join(", ")}` };
32
+ }
33
+ /** The shared check for `description` and `source`: non-empty, one line, at most `maxChars`. */
34
+ function lineRefusal(field, text, maxChars) {
35
+ if (text === "")
36
+ return { field, reason: `${field} must not be empty` };
37
+ if (CONTROL_OR_LINE_BREAK.test(text)) {
38
+ return { field, reason: `${field} must not contain a line break or control character` };
39
+ }
40
+ if (text.length > maxChars)
41
+ return { field, reason: `${field} must be at most ${maxChars} characters` };
42
+ return null;
43
+ }
44
+ /** The body cap counts UTF-8 bytes, so a multi-byte body can fail below the character limit. */
45
+ function bodyRefusal(body) {
46
+ if (Buffer.byteLength(body, "utf8") <= MEMORY_NOTE_BODY_MAX_BYTES)
47
+ return null;
48
+ return { field: "body", reason: `body must be at most ${MEMORY_NOTE_BODY_MAX_BYTES} bytes` };
49
+ }
50
+ /**
51
+ * A refusal when `text` carries a secret. The reason names the pattern's kind
52
+ * and never the matched text: a refusal is shown to the agent and logged.
53
+ */
54
+ function secretRefusal(field, text) {
55
+ const secret = findSecret(text);
56
+ return secret === null ? null : { field, reason: `secret (${secret.kind}) must not be stored` };
57
+ }
58
+ /**
59
+ * The first secret-shaped run in `text`, as its pattern's `kind` label. Every
60
+ * pattern carries the `/g` flag (the logger needs it for `replace`), so
61
+ * `lastIndex` is reset before each scan — otherwise a second call would resume
62
+ * where the first stopped and skip a match.
63
+ */
64
+ export function findSecret(text) {
65
+ for (const { kind, re, acceptMatch } of SECRET_VALUE_PATTERNS) {
66
+ re.lastIndex = 0;
67
+ let match = re.exec(text);
68
+ while (match !== null) {
69
+ const matched = match[0];
70
+ // A zero-length match would leave `lastIndex` unchanged and spin forever.
71
+ if (matched === "") {
72
+ re.lastIndex += 1;
73
+ }
74
+ else if (acceptMatch === undefined || acceptMatch(matched)) {
75
+ return { kind };
76
+ }
77
+ match = re.exec(text);
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+ /**
83
+ * The refusal for a forget's reason, or `null` when it may be recorded.
84
+ *
85
+ * A reason is what a later session reads to know why a note went, so it is held to the same
86
+ * one-line, non-empty, length-capped rule as a note's description: it is persisted on the
87
+ * request and inside a queued entry, and neither of those is a place to keep an essay.
88
+ */
89
+ export function forgetReasonRefusal(reason) {
90
+ return lineRefusal("reason", reason, MEMORY_FORGET_REASON_MAX_CHARS);
91
+ }
92
+ /**
93
+ * The refusal for a note write, or `null` when it may be stored. Stops at the
94
+ * first problem, in the design's order, so the caller reports one reason.
95
+ */
96
+ export function validateMemoryNoteWrite(write, source) {
97
+ return (slugRefusal(write.slug) ??
98
+ typeRefusal(write.type) ??
99
+ lineRefusal("description", write.description, MEMORY_DESCRIPTION_MAX_CHARS) ??
100
+ lineRefusal("source", source, MEMORY_SOURCE_MAX_CHARS) ??
101
+ bodyRefusal(write.body) ??
102
+ secretRefusal("description", write.description) ??
103
+ secretRefusal("body", write.body));
104
+ }
@@ -35,6 +35,15 @@ export declare const _adapterDeps: {
35
35
  */
36
36
  setTimeout: (fn: () => void, ms: number) => unknown;
37
37
  clearTimeout: (id: unknown) => void;
38
+ /**
39
+ * US-001: the clock the whole-turn deadline measures against, injectable for
40
+ * the same reason as the timer pair above. `createTurnDeadline` reads it at
41
+ * every check, so a test can advance a virtual clock past the budget without
42
+ * waiting the schema's one-second minimum in real time.
43
+ *
44
+ * @internal
45
+ */
46
+ now: () => number;
38
47
  };
39
48
  /**
40
49
  * US-006: `{ auth }` for the identity the credential store observed for
@@ -31,6 +31,15 @@ export const _adapterDeps = {
31
31
  */
32
32
  setTimeout: ((fn, ms) => setTimeout(fn, ms)),
33
33
  clearTimeout: ((id) => clearTimeout(id)),
34
+ /**
35
+ * US-001: the clock the whole-turn deadline measures against, injectable for
36
+ * the same reason as the timer pair above. `createTurnDeadline` reads it at
37
+ * every check, so a test can advance a virtual clock past the budget without
38
+ * waiting the schema's one-second minimum in real time.
39
+ *
40
+ * @internal
41
+ */
42
+ now: () => Date.now(),
34
43
  };
35
44
  /**
36
45
  * US-006: `{ auth }` for the identity the credential store observed for
@@ -120,6 +120,9 @@ export function toProviderOverrides(overrides) {
120
120
  ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
121
121
  ...(model.input !== undefined ? { input: model.input } : {}),
122
122
  ...(model.thinkingFormat !== undefined ? { thinkingFormat: model.thinkingFormat } : {}),
123
+ ...(model.thinkingBudgetField !== undefined ? { thinkingBudgetField: model.thinkingBudgetField } : {}),
124
+ // Copy model declarations so later config mutations cannot change client budgets.
125
+ ...(model.thinkingBudgets !== undefined ? { thinkingBudgets: { ...model.thinkingBudgets } } : {}),
123
126
  supportsTools: model.supportsTools,
124
127
  thinkingLevels: model.thinkingLevels,
125
128
  // nax#2191: forwarded verbatim. nax-ai's `assertOverrideModelRouting`
@@ -73,6 +73,8 @@ export declare function keepBudget(window: number, cfg: ResolvedCompaction, aggr
73
73
  */
74
74
  export declare const COMPACTION_SUMMARY_PREFIX = "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
75
75
  export declare const COMPACTION_SUMMARY_SUFFIX = "\n</summary>\n";
76
+ /** One summary message. The single place its shape is built, so both appliers agree. */
77
+ export declare function summaryMessage(summary: string): TranscriptMessage;
76
78
  /**
77
79
  * Walk backwards accumulating estimated size; once `keepTokens` is reached, take
78
80
  * the nearest valid cut at or after that point.
@@ -90,5 +92,17 @@ export interface CompactionPlan {
90
92
  readonly toSummarize: readonly TranscriptMessage[];
91
93
  readonly previousSummary?: string;
92
94
  }
93
- export declare function prepareCompaction(messages: readonly TranscriptMessage[], keepTokens: number): CompactionPlan | undefined;
95
+ export interface CompactionOptions {
96
+ /**
97
+ * Keep `messages[0]` out of the span and in place (the default), which is what
98
+ * an automatic compaction does: the run's task prompt survives every compaction
99
+ * byte-identical (design §Pinning). A MANUAL compaction passes `false` — its
100
+ * summary takes that position (US-002 AC-2) — and must then summarize the
101
+ * opening message, or its requirements and images reach neither the summarizer
102
+ * nor the compacted transcript. `false` starts the span at the opening message
103
+ * even when an automatic summary sits right behind it at index 1.
104
+ */
105
+ readonly pinFirstMessage?: boolean;
106
+ }
107
+ export declare function prepareCompaction(messages: readonly TranscriptMessage[], keepTokens: number, options?: CompactionOptions): CompactionPlan | undefined;
94
108
  export declare function applyCompaction(messages: readonly TranscriptMessage[], plan: CompactionPlan, summary: string): TranscriptMessage[];
@@ -98,6 +98,14 @@ export const COMPACTION_SUMMARY_SUFFIX = "\n</summary>\n";
98
98
  const PIN_INDEX = 0;
99
99
  /** Where the summary sits once written. */
100
100
  const SUMMARY_INDEX = 1;
101
+ /** One summary message. The single place its shape is built, so both appliers agree. */
102
+ export function summaryMessage(summary) {
103
+ return { role: "user", content: COMPACTION_SUMMARY_PREFIX + summary + COMPACTION_SUMMARY_SUFFIX };
104
+ }
105
+ /** True when `message` is one this module wrote as a summary. */
106
+ function isSummaryMessage(message) {
107
+ return message?.role === "user" && message.content.startsWith(COMPACTION_SUMMARY_PREFIX);
108
+ }
101
109
  /**
102
110
  * Valid cut points are user or assistant messages, NEVER a tool-result.
103
111
  *
@@ -141,38 +149,97 @@ export function findCutPoint(messages, startIndex, keepTokens) {
141
149
  }
142
150
  return candidate;
143
151
  }
144
- /** Reads a previous summary out of the message at SUMMARY_INDEX, if one is there. */
152
+ /**
153
+ * Reads a previous summary out of the transcript.
154
+ *
155
+ * Two positions are possible. An automatic compaction pins messages[0] and
156
+ * writes the summary at `SUMMARY_INDEX`; a MANUAL compaction leaves no task
157
+ * prompt to pin, so its summary sits at `PIN_INDEX` (US-002 AC-2). Both are read
158
+ * so a second compaction MERGES instead of silently dropping the earlier
159
+ * summary — the alternative was a manual compaction quietly losing the only
160
+ * record of the work it shed. Newest first: after an automatic compaction on a
161
+ * manual one, the summary further in is the merged, newer text.
162
+ */
145
163
  function readPreviousSummary(messages) {
146
- const candidate = messages[SUMMARY_INDEX];
147
- if (candidate === undefined || candidate.role !== "user")
148
- return undefined;
149
- if (!candidate.content.startsWith(COMPACTION_SUMMARY_PREFIX))
150
- return undefined;
151
- return candidate.content.slice(COMPACTION_SUMMARY_PREFIX.length, -COMPACTION_SUMMARY_SUFFIX.length);
164
+ for (const index of [SUMMARY_INDEX, PIN_INDEX]) {
165
+ const candidate = messages[index];
166
+ if (isSummaryMessage(candidate)) {
167
+ return {
168
+ text: candidate.content.slice(COMPACTION_SUMMARY_PREFIX.length, -COMPACTION_SUMMARY_SUFFIX.length),
169
+ index,
170
+ };
171
+ }
172
+ }
173
+ return undefined;
152
174
  }
153
- export function prepareCompaction(messages, keepTokens) {
154
- const previousSummary = readPreviousSummary(messages);
175
+ /**
176
+ * Where the span to summarize begins.
177
+ *
178
+ * A PINNED transcript (the automatic path) starts after `messages[0]`, or after
179
+ * any summary already in it — that summary is merged, not re-summarized.
180
+ *
181
+ * An UNPINNED one (manual compaction) starts at `messages[0]` whatever else the
182
+ * transcript holds. Its summary takes the pin's position, so the opening message
183
+ * is being replaced rather than kept, and starting anywhere later would discard
184
+ * it — requirements, constraints and images included — without summarizing it.
185
+ * That case is real: after an automatic compaction the opening message is still
186
+ * at index 0 with the automatic summary right behind it at index 1.
187
+ */
188
+ function spanStartFor(previous, options) {
189
+ if (options.pinFirstMessage === false)
190
+ return PIN_INDEX;
191
+ return previous !== undefined ? previous.index + 1 : PIN_INDEX + 1;
192
+ }
193
+ /**
194
+ * The messages the summarizer is handed: `[spanStart, cutIndex)` without the
195
+ * summary that `previousSummary` already carries, so a merge pays once for that
196
+ * text (as the merge instruction) instead of twice.
197
+ */
198
+ function spanOf(messages, spanStart, cutIndex, previous) {
199
+ const span = messages.slice(spanStart, cutIndex);
200
+ if (previous === undefined || previous.index < spanStart)
201
+ return span;
202
+ return span.filter((_message, offset) => spanStart + offset !== previous.index);
203
+ }
204
+ export function prepareCompaction(messages, keepTokens, options = {}) {
205
+ const previous = readPreviousSummary(messages);
155
206
  // Everything from here is fair game; the pin, and any existing summary, are not.
156
- const spanStart = previousSummary === undefined ? PIN_INDEX + 1 : SUMMARY_INDEX + 1;
157
- const cutIndex = findCutPoint(messages, spanStart, keepTokens);
207
+ const spanStart = spanStartFor(previous, options);
208
+ const cut = findCutPoint(messages, spanStart, keepTokens);
209
+ // Nothing to drop: the whole history fits the keep budget, so the cut is where
210
+ // the span starts and there is no plan (nax#1842). Checked on the RAW cut,
211
+ // before the clamp below — clamping first would turn "everything fits" into a
212
+ // paid compaction of the opening message (AC-5/AC-6).
213
+ if (cut <= spanStart)
214
+ return undefined;
215
+ // The kept window never starts at or before a summary that is being merged:
216
+ // its content already travels as `previousSummary`, and keeping it as well
217
+ // would leave two summaries in the transcript (the "merged, not stacked"
218
+ // rule). Reachable only when nothing is pinned — the manual path, whose span
219
+ // can now begin before that summary.
220
+ const cutIndex = previous !== undefined ? Math.max(cut, previous.index + 1) : cut;
221
+ const toSummarize = spanOf(messages, spanStart, cutIndex, previous);
158
222
  // An empty span is "nothing to do" whether or not a summary already exists
159
223
  // (nax#1842). A merge plan is still returnable — that needs `previousSummary`
160
- // AND new content, which is exactly `cutIndex > spanStart`. With an empty span
161
- // there is nothing to fold in: summarize() would be paid to re-summarize the
162
- // previous summary alone and applyCompaction would rebuild an array no smaller
163
- // than the one it replaced, once per round trip for the rest of the turn.
164
- if (cutIndex <= spanStart)
224
+ // AND new content. With an empty span there is nothing to fold in:
225
+ // summarize() would be paid to re-summarize the previous summary alone and
226
+ // applyCompaction would rebuild an array no smaller than the one it replaced,
227
+ // once per round trip for the rest of the turn. Measured on the span itself
228
+ // because excluding a summary from it can empty a range that was not empty.
229
+ if (toSummarize.length === 0)
165
230
  return undefined;
166
231
  return {
167
232
  cutIndex,
168
- toSummarize: messages.slice(spanStart, cutIndex),
169
- ...(previousSummary !== undefined ? { previousSummary } : {}),
233
+ toSummarize,
234
+ ...(previous !== undefined ? { previousSummary: previous.text } : {}),
170
235
  };
171
236
  }
172
237
  export function applyCompaction(messages, plan, summary) {
173
- return [
174
- messages[PIN_INDEX],
175
- { role: "user", content: COMPACTION_SUMMARY_PREFIX + summary + COMPACTION_SUMMARY_SUFFIX },
176
- ...messages.slice(plan.cutIndex),
177
- ];
238
+ // A summary AT the pin is one this session already compacted into — a manual
239
+ // compaction writes it there (applyManualCompaction), because that path has no
240
+ // task prompt worth pinning. Keeping it as the pin would stack two summaries,
241
+ // the older one out of reach of the merge rule above and so never removable;
242
+ // the new summary replaces it instead (US-002).
243
+ const pin = isSummaryMessage(messages[PIN_INDEX]) ? [] : [messages[PIN_INDEX]];
244
+ return [...pin, summaryMessage(summary), ...messages.slice(plan.cutIndex)];
178
245
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * US-002: on-demand compaction of a native session's stored history.
3
+ *
4
+ * Automatic compaction fires near the context limit. This is the person saying
5
+ * the finished exploration is over — so the same pure core (`prepareCompaction`,
6
+ * `summarizeAndApply`) runs against the stored transcript, with three deliberate
7
+ * differences from the automatic path:
8
+ *
9
+ * 1. it ignores `compaction.enabled`, which governs when compaction happens BY
10
+ * ITSELF; asking for it is not the same decision;
11
+ * 2. it dispatches no `before_compaction` loop event, so a plugin handler sees
12
+ * only proactive and overflow compactions and cannot veto or rewrite a
13
+ * summary the person asked for;
14
+ * 3. it does not pin the first message. `applyCompaction` keeps `messages[0]`
15
+ * so a run's task prompt survives every compaction (design §Pinning, and its
16
+ * cache-prefix rationale); a chat exploration has no such prompt worth
17
+ * keeping, and the next request must BEGIN with the summary (AC-2), so the
18
+ * summary takes that position instead — see `applyManualCompaction`. The
19
+ * opening message is then part of the span (`pinFirstMessage: false`), so
20
+ * what it said is summarized rather than discarded with the pin.
21
+ *
22
+ * A summarizer failure propagates and NOTHING is written: the loaded array is a
23
+ * fresh copy, so a rejection leaves the stored history exactly as it was.
24
+ */
25
+ import type { Client } from "@nathapp/nax-ai";
26
+ import type { CompactResult } from "#src/session/agent-session-types";
27
+ import type { SessionHandle } from "#src/session/session-types";
28
+ import type { TurnDeadline } from "#src/session/turn-deadline";
29
+ import { type CompactionPlan, type TranscriptMessage } from "./compaction.ts";
30
+ import type { NativeSessionState } from "./session.ts";
31
+ export interface ManualCompactionRequest {
32
+ /** The adapter's per-session state: transcript, model identity and saved settings all live there. */
33
+ readonly state: NativeSessionState;
34
+ readonly handle: SessionHandle;
35
+ /** The session's client, supplied by the adapter that owns it. */
36
+ readonly client: Client;
37
+ /** The facade's budget for this call, consulted per summarizer call. */
38
+ readonly deadline: TurnDeadline;
39
+ /** Aborts on that deadline or on `close()`; a summarizer throw propagates instead. */
40
+ readonly signal: AbortSignal;
41
+ }
42
+ /**
43
+ * The manual compacted array: the summary first, then the kept window.
44
+ *
45
+ * Built from the plan rather than by post-processing `applyCompaction`'s array:
46
+ * slicing its first element would be wrong the moment `applyCompaction` itself
47
+ * drops a pin — which it does for a transcript this path already compacted.
48
+ */
49
+ export declare function applyManualCompaction(messages: readonly TranscriptMessage[], plan: CompactionPlan, summary: string): TranscriptMessage[];
50
+ /**
51
+ * Compact one session's stored history now.
52
+ *
53
+ * Nothing is written until a plan exists and the summary has come back, so
54
+ * "nothing to drop" costs no model call and no I/O, and a failed summary costs
55
+ * the history nothing beyond the failed call itself.
56
+ */
57
+ export declare function compactHistory(request: ManualCompactionRequest): Promise<CompactResult>;
@@ -0,0 +1,72 @@
1
+ import { NaxError } from "#src/infra/index";
2
+ import { estimateContextTokens, keepBudget, prepareCompaction, summaryMessage, } from "./compaction.js";
3
+ import { sessionTranscriptFor } from "./session.js";
4
+ import { createSessionSummarizer, resolveSessionModel } from "./session-summarizer.js";
5
+ import { historyFromTranscript, transcriptDocFor, transcriptIdentityFor } from "./transcript-identity.js";
6
+ import { summarizeAndApply } from "./turn-compaction-step.js";
7
+ /** No model call was made, so zero spend is the honest answer. */
8
+ const ZERO_USAGE = { inputTokens: 0, outputTokens: 0 };
9
+ /**
10
+ * The manual compacted array: the summary first, then the kept window.
11
+ *
12
+ * Built from the plan rather than by post-processing `applyCompaction`'s array:
13
+ * slicing its first element would be wrong the moment `applyCompaction` itself
14
+ * drops a pin — which it does for a transcript this path already compacted.
15
+ */
16
+ export function applyManualCompaction(messages, plan, summary) {
17
+ return [summaryMessage(summary), ...messages.slice(plan.cutIndex)];
18
+ }
19
+ /**
20
+ * Compact one session's stored history now.
21
+ *
22
+ * Nothing is written until a plan exists and the summary has come back, so
23
+ * "nothing to drop" costs no model call and no I/O, and a failed summary costs
24
+ * the history nothing beyond the failed call itself.
25
+ */
26
+ export async function compactHistory(request) {
27
+ const { state, handle, client, deadline, signal } = request;
28
+ const transcript = sessionTranscriptFor(state, handle.id);
29
+ if (transcript === undefined) {
30
+ throw new NaxError(`no transcript store for session "${handle.id}"`, "NATIVE_TRANSCRIPT_DIR_MISSING", {
31
+ stage: "native-session",
32
+ });
33
+ }
34
+ const identity = transcriptIdentityFor(state, handle);
35
+ // Reloaded from the store, never held in memory: the adapter keeps no
36
+ // conversation, so what is on disk is the only history there is.
37
+ const messages = [...historyFromTranscript(await transcript.store.load(handle.id), identity, handle.id)];
38
+ const tokensBefore = estimateContextTokens(messages, undefined, undefined);
39
+ const resolution = await resolveSessionModel(client, state, handle);
40
+ // `pinFirstMessage: false`: this path writes the summary where the pin would
41
+ // be, so the opening message has to be inside the span. Leaving it out would
42
+ // drop the opening requirements, constraints and images without summarizing
43
+ // them — they would reach neither the model nor the compacted transcript.
44
+ const plan = prepareCompaction(messages, keepBudget(resolution.contextWindow, resolution.compaction), {
45
+ pinFirstMessage: false,
46
+ });
47
+ if (plan === undefined) {
48
+ return { compacted: false, tokensBefore, tokensAfter: tokensBefore, usage: ZERO_USAGE, costUsd: 0 };
49
+ }
50
+ const applied = await summarizeAndApply(messages, plan, {
51
+ summarize: createSessionSummarizer({ resolution, deadline, signal }),
52
+ contextWindow: resolution.contextWindow,
53
+ compaction: resolution.compaction,
54
+ });
55
+ const compacted = applyManualCompaction(messages, plan, applied.summary.text);
56
+ // Saved with the same owner and model identity a turn save uses, so the next
57
+ // turn — and a resume — reads this array as its own history, not a foreign
58
+ // one. A write failure propagates: continuing from a history that could not be
59
+ // stored is the silent degradation ADR-028 §4 removed.
60
+ await transcript.store.save(handle.id, transcriptDocFor(compacted, identity));
61
+ // The anchor describes the array the provider was last charged for. Against
62
+ // the compacted one it is meaningless, and leaving it would make the next turn
63
+ // estimate from the OLD size and compact again straight away (AC-16).
64
+ state.lastUsage.delete(handle.id);
65
+ return {
66
+ compacted: true,
67
+ tokensBefore,
68
+ tokensAfter: estimateContextTokens(compacted, undefined, undefined),
69
+ usage: applied.summary.usage,
70
+ costUsd: applied.summary.costUsd,
71
+ };
72
+ }