@pugi/cli 0.1.0-beta.4 → 0.1.0-beta.41

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 (250) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -25
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/commands/smoke.js +133 -0
  7. package/dist/core/agent-progress/cleanup.js +134 -0
  8. package/dist/core/agent-progress/schema.js +144 -0
  9. package/dist/core/agent-progress/writer.js +101 -0
  10. package/dist/core/artifact-chain/dispatcher.js +148 -0
  11. package/dist/core/artifact-chain/exporter.js +164 -0
  12. package/dist/core/artifact-chain/state.js +243 -0
  13. package/dist/core/artifact-chain/steps.js +169 -0
  14. package/dist/core/auth/ensure-authenticated.js +129 -0
  15. package/dist/core/auth/env-provider.js +238 -0
  16. package/dist/core/auto-update/channels.js +122 -0
  17. package/dist/core/auto-update/checker.js +241 -0
  18. package/dist/core/auto-update/state.js +235 -0
  19. package/dist/core/bare-mode/index.js +107 -0
  20. package/dist/core/bash-classifier.js +108 -1
  21. package/dist/core/checkpoint/resumer.js +149 -0
  22. package/dist/core/checkpoint/rewinder.js +291 -0
  23. package/dist/core/codegraph/decision-store.js +248 -0
  24. package/dist/core/codegraph/detect-repo.js +459 -0
  25. package/dist/core/codegraph/install.js +134 -0
  26. package/dist/core/codegraph/offer-hook.js +220 -0
  27. package/dist/core/compact/auto-trigger.js +96 -0
  28. package/dist/core/compact/buffer-rewriter.js +115 -0
  29. package/dist/core/compact/summarizer.js +208 -0
  30. package/dist/core/compact/token-counter.js +108 -0
  31. package/dist/core/consensus/diff-capture.js +73 -0
  32. package/dist/core/context/index.js +7 -0
  33. package/dist/core/context/markdown-traverse.js +255 -0
  34. package/dist/core/cost/rate-card.js +129 -0
  35. package/dist/core/cost/tracker.js +221 -0
  36. package/dist/core/denial-tracking/index.js +8 -0
  37. package/dist/core/denial-tracking/state.js +264 -0
  38. package/dist/core/diagnostics/probe-runner.js +93 -0
  39. package/dist/core/diagnostics/probes/api.js +46 -0
  40. package/dist/core/diagnostics/probes/auth.js +86 -0
  41. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  42. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  43. package/dist/core/diagnostics/probes/config.js +72 -0
  44. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  45. package/dist/core/diagnostics/probes/disk.js +81 -0
  46. package/dist/core/diagnostics/probes/git.js +65 -0
  47. package/dist/core/diagnostics/probes/mcp.js +75 -0
  48. package/dist/core/diagnostics/probes/node.js +59 -0
  49. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  50. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  51. package/dist/core/diagnostics/probes/session.js +74 -0
  52. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  53. package/dist/core/diagnostics/probes/workspace.js +63 -0
  54. package/dist/core/diagnostics/types.js +70 -0
  55. package/dist/core/dispatch/cache-cleanup.js +197 -0
  56. package/dist/core/dispatch/cache-handoff.js +295 -0
  57. package/dist/core/edits/dispatch.js +218 -2
  58. package/dist/core/edits/journal.js +199 -0
  59. package/dist/core/edits/layer-d-ast.js +557 -14
  60. package/dist/core/edits/verify-hook.js +273 -0
  61. package/dist/core/edits/worktree.js +322 -0
  62. package/dist/core/engine/anvil-client.js +115 -5
  63. package/dist/core/engine/budgets.js +98 -0
  64. package/dist/core/engine/context-prefix.js +155 -0
  65. package/dist/core/engine/intent.js +260 -0
  66. package/dist/core/engine/native-pugi.js +860 -211
  67. package/dist/core/engine/prompts.js +88 -2
  68. package/dist/core/engine/strip-internal-fields.js +124 -0
  69. package/dist/core/engine/tool-bridge.js +1045 -36
  70. package/dist/core/feedback/queue.js +177 -0
  71. package/dist/core/feedback/submitter.js +145 -0
  72. package/dist/core/file-cache.js +113 -1
  73. package/dist/core/hooks/events.js +44 -0
  74. package/dist/core/hooks/index.js +15 -0
  75. package/dist/core/hooks/registry.js +213 -0
  76. package/dist/core/hooks/runner.js +236 -0
  77. package/dist/core/hooks/v2/event-emitter.js +115 -0
  78. package/dist/core/hooks/v2/executor.js +282 -0
  79. package/dist/core/hooks/v2/index.js +25 -0
  80. package/dist/core/hooks/v2/lifecycle.js +104 -0
  81. package/dist/core/hooks/v2/loader.js +216 -0
  82. package/dist/core/hooks/v2/matcher.js +125 -0
  83. package/dist/core/hooks/v2/trust.js +143 -0
  84. package/dist/core/hooks/v2/types.js +86 -0
  85. package/dist/core/lsp/cache.js +105 -0
  86. package/dist/core/lsp/client.js +776 -0
  87. package/dist/core/lsp/language-detect.js +66 -0
  88. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  89. package/dist/core/mcp/client.js +75 -6
  90. package/dist/core/mcp/http-server.js +553 -0
  91. package/dist/core/mcp/orchestrator-tools.js +662 -0
  92. package/dist/core/mcp/permission.js +190 -0
  93. package/dist/core/mcp/registry.js +24 -2
  94. package/dist/core/mcp/server-tools.js +219 -0
  95. package/dist/core/mcp/server.js +397 -0
  96. package/dist/core/memory/dual-write.js +416 -0
  97. package/dist/core/memory/phase1-kinds.js +20 -0
  98. package/dist/core/memory-sync/queue.js +158 -0
  99. package/dist/core/onboarding/ensure-initialized.js +133 -0
  100. package/dist/core/onboarding/marker.js +111 -0
  101. package/dist/core/onboarding/telemetry-state.js +108 -0
  102. package/dist/core/output-style/presets.js +176 -0
  103. package/dist/core/output-style/state.js +185 -0
  104. package/dist/core/permissions/auto-classifier.js +124 -0
  105. package/dist/core/permissions/circuit-breaker.js +83 -0
  106. package/dist/core/permissions/gate.js +278 -0
  107. package/dist/core/permissions/index.js +20 -0
  108. package/dist/core/permissions/mode.js +174 -0
  109. package/dist/core/permissions/state.js +241 -0
  110. package/dist/core/permissions/tool-class.js +93 -0
  111. package/dist/core/prd-check/parser.js +215 -0
  112. package/dist/core/prd-check/reporter.js +127 -0
  113. package/dist/core/prd-check/session-review.js +557 -0
  114. package/dist/core/prd-check/verifiers.js +223 -0
  115. package/dist/core/pugi-md/context-injector.js +76 -0
  116. package/dist/core/pugi-md/walk-up.js +207 -0
  117. package/dist/core/release-notes/parser.js +241 -0
  118. package/dist/core/release-notes/state.js +116 -0
  119. package/dist/core/repl/history.js +11 -1
  120. package/dist/core/repl/model-pricing.js +135 -0
  121. package/dist/core/repl/session.js +1899 -38
  122. package/dist/core/repl/slash-commands.js +406 -21
  123. package/dist/core/repl/store/session-store.js +31 -2
  124. package/dist/core/repl/workspace-context.js +22 -0
  125. package/dist/core/repo-map/build.js +125 -0
  126. package/dist/core/repo-map/cache.js +185 -0
  127. package/dist/core/repo-map/extractor.js +254 -0
  128. package/dist/core/repo-map/formatter.js +145 -0
  129. package/dist/core/repo-map/scanner.js +211 -0
  130. package/dist/core/retry-budget/budget.js +284 -0
  131. package/dist/core/retry-budget/index.js +5 -0
  132. package/dist/core/session.js +92 -0
  133. package/dist/core/settings.js +80 -0
  134. package/dist/core/share/formatter.js +271 -0
  135. package/dist/core/share/redactor.js +221 -0
  136. package/dist/core/share/uploader.js +267 -0
  137. package/dist/core/skills/defaults.js +457 -0
  138. package/dist/core/smoke/headless-driver.js +174 -0
  139. package/dist/core/smoke/orchestrator.js +194 -0
  140. package/dist/core/smoke/runner.js +238 -0
  141. package/dist/core/smoke/scenario-parser.js +316 -0
  142. package/dist/core/subagents/dispatcher-real.js +600 -0
  143. package/dist/core/subagents/dispatcher.js +113 -24
  144. package/dist/core/subagents/index.js +18 -5
  145. package/dist/core/subagents/isolation-matrix.js +213 -0
  146. package/dist/core/subagents/spawn.js +19 -4
  147. package/dist/core/telemetry/emitter.js +229 -0
  148. package/dist/core/telemetry/queue.js +251 -0
  149. package/dist/core/theme/context.js +91 -0
  150. package/dist/core/theme/presets.js +228 -0
  151. package/dist/core/theme/state.js +181 -0
  152. package/dist/core/todos/invariant.js +10 -0
  153. package/dist/core/todos/state.js +177 -0
  154. package/dist/core/transport/version-interceptor.js +166 -0
  155. package/dist/core/vim/keymap.js +288 -0
  156. package/dist/core/vim/state.js +92 -0
  157. package/dist/index.js +28 -0
  158. package/dist/runtime/bootstrap.js +190 -0
  159. package/dist/runtime/cli.js +3073 -321
  160. package/dist/runtime/commands/cancel.js +231 -0
  161. package/dist/runtime/commands/chain.js +489 -0
  162. package/dist/runtime/commands/codegraph-status.js +227 -0
  163. package/dist/runtime/commands/compact.js +297 -0
  164. package/dist/runtime/commands/cost.js +199 -0
  165. package/dist/runtime/commands/delegate.js +242 -11
  166. package/dist/runtime/commands/dispatch.js +126 -0
  167. package/dist/runtime/commands/doctor.js +390 -0
  168. package/dist/runtime/commands/feedback.js +184 -0
  169. package/dist/runtime/commands/hooks.js +184 -0
  170. package/dist/runtime/commands/lsp.js +368 -0
  171. package/dist/runtime/commands/mcp.js +879 -0
  172. package/dist/runtime/commands/memory.js +508 -0
  173. package/dist/runtime/commands/model.js +237 -0
  174. package/dist/runtime/commands/onboarding.js +275 -0
  175. package/dist/runtime/commands/patch.js +128 -0
  176. package/dist/runtime/commands/permissions.js +112 -0
  177. package/dist/runtime/commands/plan.js +143 -0
  178. package/dist/runtime/commands/prd-check.js +285 -0
  179. package/dist/runtime/commands/redo-blob-store.js +92 -0
  180. package/dist/runtime/commands/redo.js +361 -0
  181. package/dist/runtime/commands/release-notes.js +229 -0
  182. package/dist/runtime/commands/repo-map.js +95 -0
  183. package/dist/runtime/commands/report.js +299 -0
  184. package/dist/runtime/commands/resume.js +118 -0
  185. package/dist/runtime/commands/review-consensus.js +17 -2
  186. package/dist/runtime/commands/rewind.js +333 -0
  187. package/dist/runtime/commands/sessions.js +163 -0
  188. package/dist/runtime/commands/share.js +316 -0
  189. package/dist/runtime/commands/status.js +186 -0
  190. package/dist/runtime/commands/stickers.js +82 -0
  191. package/dist/runtime/commands/style.js +194 -0
  192. package/dist/runtime/commands/theme.js +196 -0
  193. package/dist/runtime/commands/undo.js +32 -0
  194. package/dist/runtime/commands/update.js +289 -0
  195. package/dist/runtime/commands/vim.js +140 -0
  196. package/dist/runtime/commands/worktree.js +177 -0
  197. package/dist/runtime/headless-repl.js +195 -0
  198. package/dist/runtime/headless.js +543 -0
  199. package/dist/runtime/load-hooks-or-exit.js +71 -0
  200. package/dist/runtime/plan-decompose.js +531 -0
  201. package/dist/runtime/version.js +65 -0
  202. package/dist/tools/agent-tool.js +229 -0
  203. package/dist/tools/apply-patch.js +556 -0
  204. package/dist/tools/ask-user-question.js +213 -0
  205. package/dist/tools/ask-user.js +115 -0
  206. package/dist/tools/file-tools.js +85 -14
  207. package/dist/tools/lsp-tools.js +189 -0
  208. package/dist/tools/mcp-tool.js +260 -0
  209. package/dist/tools/multi-edit.js +361 -0
  210. package/dist/tools/powershell.js +156 -0
  211. package/dist/tools/registry.js +51 -0
  212. package/dist/tools/skill-tool.js +96 -0
  213. package/dist/tools/tasks.js +208 -0
  214. package/dist/tools/todo-write.js +184 -0
  215. package/dist/tools/web-fetch.js +147 -2
  216. package/dist/tools/web-search.js +458 -0
  217. package/dist/tui/agent-progress-card.js +111 -0
  218. package/dist/tui/agent-tree.js +10 -0
  219. package/dist/tui/ask-modal.js +2 -2
  220. package/dist/tui/ask-user-question-prompt.js +192 -0
  221. package/dist/tui/compact-banner.js +81 -0
  222. package/dist/tui/conversation-pane.js +82 -8
  223. package/dist/tui/cost-table.js +111 -0
  224. package/dist/tui/doctor-table.js +46 -0
  225. package/dist/tui/feedback-prompt.js +156 -0
  226. package/dist/tui/input-box.js +69 -2
  227. package/dist/tui/markdown-render.js +4 -4
  228. package/dist/tui/onboarding-wizard.js +240 -0
  229. package/dist/tui/permissions-picker.js +86 -0
  230. package/dist/tui/render.js +35 -0
  231. package/dist/tui/repl-render.js +303 -13
  232. package/dist/tui/repl-splash.js +2 -2
  233. package/dist/tui/repl.js +72 -14
  234. package/dist/tui/splash.js +1 -1
  235. package/dist/tui/status-bar.js +94 -16
  236. package/dist/tui/status-table.js +7 -0
  237. package/dist/tui/stickers-art.js +136 -0
  238. package/dist/tui/style-table.js +28 -0
  239. package/dist/tui/theme-table.js +29 -0
  240. package/dist/tui/tool-stream-pane.js +52 -3
  241. package/dist/tui/update-banner.js +20 -2
  242. package/dist/tui/vim-input.js +267 -0
  243. package/docs/examples/codegraph.mcp.json +10 -0
  244. package/package.json +12 -6
  245. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  246. package/test/scenarios/compact-force.scenario.txt +11 -0
  247. package/test/scenarios/identity.scenario.txt +11 -0
  248. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  249. package/test/scenarios/walkback.scenario.txt +12 -0
  250. package/dist/core/engine/compaction-hook.js +0 -154
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Pugi CLI dual-write client — Memory Phase 1 (2026-05-27, Wave 6).
3
+ *
4
+ * Promotes the per-session NDJSON event log (Memory Phase 0, PR #522)
5
+ * to a best-effort Prisma row store via the admin-api endpoint:
6
+ *
7
+ * POST /api/pugi/sessions/:sessionId/events
8
+ *
9
+ * Design contract:
10
+ *
11
+ * 1. Local NDJSON is the SOURCE OF TRUTH. The dual-write client is
12
+ * fire-and-forget — a network failure NEVER blocks the local
13
+ * append path, and the operator never loses data because the
14
+ * .pugi/sessions/<id>/events.<n>.jsonl file is always written
15
+ * first by the SessionStore.
16
+ *
17
+ * 2. Async + debounced. Events are buffered in memory and flushed on
18
+ * a timer (default 250 ms) or when the buffer crosses the batch
19
+ * cap (default 50). The debounce + buffer keeps the network cost
20
+ * proportional to operator activity, not per-event.
21
+ *
22
+ * 3. Retry with backoff. A failed flush retries up to 3 times with
23
+ * exponential backoff (250 ms / 750 ms / 2250 ms). Failure beyond
24
+ * that drops the batch; the next flush brings in the backlog from
25
+ * the local NDJSON via the resume path.
26
+ *
27
+ * 4. Resume marker. The client tracks `lastSyncedSeq` per session in
28
+ * `~/.pugi/memory-sync/<sessionId>.json`. On `flushBacklog()` it
29
+ * reads any events from the local NDJSON whose seq > lastSyncedSeq
30
+ * and posts them in order. The marker is updated only on a
31
+ * successful POST so a crash mid-flush retries the unsent batch.
32
+ *
33
+ * 5. Kill switch. `PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED=false`
34
+ * shuts the dual-write off entirely (env var, no settings file
35
+ * round-trip). The client is also a no-op when no credentials are
36
+ * available — anon CLI sessions never POST.
37
+ *
38
+ * 6. Schema contract. The kinds mirror
39
+ * `apps/admin-api/src/pugi-session-events/pugi-session-events.types.ts`
40
+ * PUGI_SESSION_EVENT_KINDS. The CLI's broader NDJSON kind set
41
+ * (`user`, `persona`, `rewind-marker`, etc.) is REMAPPED to the
42
+ * Phase-1 closed set inside `eventToPhase1()` — adding a new
43
+ * Phase-1 kind requires a coordinated CLI + server bump.
44
+ *
45
+ * The client carries no Nest / Prisma deps — pure node:fs + global
46
+ * fetch so it boots inside the CLI's REPL workflow without dragging in
47
+ * the admin-api graph.
48
+ */
49
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
50
+ import { homedir } from 'node:os';
51
+ import { resolve } from 'node:path';
52
+ import { PUGI_SESSION_EVENT_KINDS, } from './phase1-kinds.js';
53
+ /* ------------------------------------------------------------------ */
54
+ /* Constants */
55
+ /* ------------------------------------------------------------------ */
56
+ export const DEFAULT_DEBOUNCE_MS = 250;
57
+ export const DEFAULT_MAX_BATCH_SIZE = 50;
58
+ export const DEFAULT_MAX_RETRIES = 3;
59
+ /** Server-side hard ceiling (mirror of admin-api MAX_BATCH_EVENTS). */
60
+ const SERVER_MAX_BATCH_EVENTS = 200;
61
+ /** Env kill-switch. Unset / 'true' / '1' = enabled. 'false' / '0' = off. */
62
+ export function isDualWriteEnabledFromEnv(env = process.env) {
63
+ const raw = env.PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED;
64
+ if (raw === undefined || raw === '')
65
+ return true;
66
+ const v = raw.toLowerCase().trim();
67
+ return v !== 'false' && v !== '0' && v !== 'off' && v !== 'no';
68
+ }
69
+ /** Env override for debounce (ms). Falls back to DEFAULT_DEBOUNCE_MS. */
70
+ export function debounceMsFromEnv(env = process.env) {
71
+ const raw = env.PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS;
72
+ if (!raw)
73
+ return DEFAULT_DEBOUNCE_MS;
74
+ const n = Number.parseInt(raw, 10);
75
+ if (!Number.isFinite(n) || n < 0 || n > 60_000)
76
+ return DEFAULT_DEBOUNCE_MS;
77
+ return n;
78
+ }
79
+ export function defaultStateDir(home = homedir()) {
80
+ return resolve(home, '.pugi', 'memory-sync');
81
+ }
82
+ export function syncStatePath(sessionId, stateDir) {
83
+ return resolve(stateDir, `${sessionId}.json`);
84
+ }
85
+ export function readSyncState(sessionId, stateDir = defaultStateDir()) {
86
+ const p = syncStatePath(sessionId, stateDir);
87
+ if (!existsSync(p))
88
+ return 0;
89
+ try {
90
+ const raw = readFileSync(p, 'utf-8');
91
+ const parsed = JSON.parse(raw);
92
+ if (parsed.schema === 1 &&
93
+ typeof parsed.lastSyncedSeq === 'number' &&
94
+ Number.isInteger(parsed.lastSyncedSeq) &&
95
+ parsed.lastSyncedSeq >= 0) {
96
+ return parsed.lastSyncedSeq;
97
+ }
98
+ }
99
+ catch {
100
+ // Best effort — a corrupt state file means "resync from zero".
101
+ return 0;
102
+ }
103
+ return 0;
104
+ }
105
+ export function writeSyncState(sessionId, lastSyncedSeq, stateDir = defaultStateDir()) {
106
+ if (!existsSync(stateDir)) {
107
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
108
+ }
109
+ const p = syncStatePath(sessionId, stateDir);
110
+ const record = {
111
+ schema: 1,
112
+ sessionId,
113
+ lastSyncedSeq,
114
+ updatedAt: new Date().toISOString(),
115
+ };
116
+ // Atomic write: temp + rename so a crash mid-write leaves either the
117
+ // old contents or the new — never a partial JSON document.
118
+ const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
119
+ writeFileSync(tmp, JSON.stringify(record), { encoding: 'utf-8', mode: 0o600 });
120
+ renameSync(tmp, p);
121
+ }
122
+ /* ------------------------------------------------------------------ */
123
+ /* DualWriteClient */
124
+ /* ------------------------------------------------------------------ */
125
+ /**
126
+ * Compute the next backoff delay (ms). Exponential growth from the
127
+ * debounce base — 1× / 3× / 9× — keeps the retry storm bounded.
128
+ */
129
+ export function nextBackoffMs(attempt, base) {
130
+ // attempt 1 -> base, attempt 2 -> 3×base, attempt 3 -> 9×base.
131
+ return base * Math.pow(3, Math.max(0, attempt - 1));
132
+ }
133
+ /**
134
+ * Tiny await-able sleeper. Public for tests that want a deterministic
135
+ * scheduler — production callers go through the internal debounce
136
+ * timer, not this helper.
137
+ */
138
+ function sleep(ms, scheduler = setTimeout) {
139
+ return new Promise((res) => scheduler(res, Math.max(0, ms)));
140
+ }
141
+ /**
142
+ * Buffered, debounced, retry-capable dual-write client.
143
+ *
144
+ * One instance per active session. The CLI's SessionStore creates one
145
+ * at `openSession()` and disposes it at `archive()` / `process.exit()`.
146
+ * The instance retains its own buffer + flushing promise so concurrent
147
+ * `enqueue()` calls from multiple producers (REPL turn + subagent
148
+ * dispatcher) coalesce into the same batch.
149
+ */
150
+ export class DualWriteClient {
151
+ cfg;
152
+ buffer = [];
153
+ flushTimer = null;
154
+ /**
155
+ * Active flush promise (null when idle). Re-using the in-flight
156
+ * promise lets `enqueue` callers tail-chain a pending flush instead
157
+ * of stacking N flushes when the CLI is in a hot loop.
158
+ */
159
+ flushing = null;
160
+ /** Disposed clients reject further enqueues quietly. */
161
+ disposed = false;
162
+ /** Track the highest server-acknowledged seq. */
163
+ highestSyncedSeq;
164
+ constructor(config) {
165
+ const stateDir = config.stateDir ?? defaultStateDir();
166
+ this.cfg = {
167
+ apiUrl: config.apiUrl.replace(/\/+$/, ''),
168
+ apiKey: config.apiKey,
169
+ sessionId: config.sessionId,
170
+ debounceMs: config.debounceMs ?? debounceMsFromEnv(),
171
+ maxBatchSize: Math.min(config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE, SERVER_MAX_BATCH_EVENTS),
172
+ maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
173
+ fetchImpl: config.fetchImpl ?? globalThis.fetch,
174
+ stateDir,
175
+ };
176
+ this.highestSyncedSeq = readSyncState(config.sessionId, stateDir);
177
+ }
178
+ /** Currently-known synced seq (read-through for callers). */
179
+ get lastSyncedSeq() {
180
+ return this.highestSyncedSeq;
181
+ }
182
+ /**
183
+ * Buffer an event for the next flush. Returns immediately — the
184
+ * actual POST happens on the debounce timer.
185
+ *
186
+ * The producer (SessionStore) is expected to have ALREADY written
187
+ * the local NDJSON line before calling this. The dual-write client
188
+ * never touches the NDJSON itself.
189
+ */
190
+ enqueue(event) {
191
+ if (this.disposed)
192
+ return;
193
+ if (!isValidPhase1Kind(event.kind)) {
194
+ // Quietly ignore events the Phase-1 schema cannot represent. The
195
+ // local NDJSON keeps them; a future Phase-1.1 may widen the kind
196
+ // set.
197
+ return;
198
+ }
199
+ if (!Number.isInteger(event.seq) || event.seq <= 0)
200
+ return;
201
+ this.buffer.push(event);
202
+ // Crossed the batch cap — flush eagerly to bound buffer growth.
203
+ if (this.buffer.length >= this.cfg.maxBatchSize) {
204
+ this.scheduleFlush(0);
205
+ return;
206
+ }
207
+ this.scheduleFlush(this.cfg.debounceMs);
208
+ }
209
+ /**
210
+ * Force any pending events out. Used at session archive / shutdown
211
+ * so the trailing batch lands before the process exits.
212
+ */
213
+ async flush() {
214
+ if (this.flushTimer) {
215
+ clearTimeout(this.flushTimer);
216
+ this.flushTimer = null;
217
+ }
218
+ return this.doFlush();
219
+ }
220
+ /**
221
+ * Mark the client disposed. Subsequent enqueues are no-ops. Pending
222
+ * flush is awaited so the caller can `await client.dispose()` to
223
+ * fence completion.
224
+ */
225
+ async dispose() {
226
+ this.disposed = true;
227
+ if (this.flushTimer) {
228
+ clearTimeout(this.flushTimer);
229
+ this.flushTimer = null;
230
+ }
231
+ if (this.flushing) {
232
+ try {
233
+ await this.flushing;
234
+ }
235
+ catch {
236
+ // ignore
237
+ }
238
+ }
239
+ }
240
+ /**
241
+ * Backlog catch-up. The caller passes the local NDJSON's events
242
+ * (already filtered to the session) — the client posts any whose
243
+ * seq > lastSyncedSeq, in order, in batches of maxBatchSize.
244
+ *
245
+ * Returns the aggregate FlushResult across all batches (sums
246
+ * persisted + duplicate counts, lastSeq is the max).
247
+ */
248
+ async flushBacklog(allEvents) {
249
+ const pending = allEvents
250
+ .filter((e) => e.seq > this.highestSyncedSeq && isValidPhase1Kind(e.kind))
251
+ .sort((a, b) => a.seq - b.seq);
252
+ if (pending.length === 0) {
253
+ return {
254
+ persistedCount: 0,
255
+ duplicateCount: 0,
256
+ lastSeq: this.highestSyncedSeq,
257
+ };
258
+ }
259
+ let totalPersisted = 0;
260
+ let totalDuplicates = 0;
261
+ let maxLastSeq = this.highestSyncedSeq;
262
+ for (let i = 0; i < pending.length; i += this.cfg.maxBatchSize) {
263
+ const batch = pending.slice(i, i + this.cfg.maxBatchSize);
264
+ const result = await this.postBatchWithRetry(batch);
265
+ if (!result)
266
+ break; // surrender — backlog stays for next session
267
+ totalPersisted += result.persistedCount;
268
+ totalDuplicates += result.duplicateCount;
269
+ maxLastSeq = Math.max(maxLastSeq, result.lastSeq);
270
+ }
271
+ return {
272
+ persistedCount: totalPersisted,
273
+ duplicateCount: totalDuplicates,
274
+ lastSeq: maxLastSeq,
275
+ };
276
+ }
277
+ /* ---------------- internal ---------------- */
278
+ scheduleFlush(delay) {
279
+ if (this.flushTimer)
280
+ clearTimeout(this.flushTimer);
281
+ this.flushTimer = setTimeout(() => {
282
+ this.flushTimer = null;
283
+ // doFlush handles its own error swallowing; we drop the floating
284
+ // promise on the floor on purpose so the timer callback returns
285
+ // synchronously (Node requires that).
286
+ void this.doFlush();
287
+ }, delay);
288
+ }
289
+ async doFlush() {
290
+ if (this.flushing)
291
+ return this.flushing;
292
+ if (this.buffer.length === 0)
293
+ return null;
294
+ const drained = this.buffer.splice(0, this.buffer.length);
295
+ this.flushing = this.postBatchWithRetry(drained);
296
+ try {
297
+ return await this.flushing;
298
+ }
299
+ finally {
300
+ this.flushing = null;
301
+ }
302
+ }
303
+ async postBatchWithRetry(batch) {
304
+ if (batch.length === 0)
305
+ return null;
306
+ // Sort + within-batch dedup (defence in depth — the server also
307
+ // enforces monotonic seq + dedup, but enforcing here keeps the
308
+ // wire payload clean and avoids the 400 on a buggy producer).
309
+ const sorted = [...batch].sort((a, b) => a.seq - b.seq);
310
+ const deduped = [];
311
+ let prevSeq = -1;
312
+ for (const ev of sorted) {
313
+ if (ev.seq === prevSeq)
314
+ continue;
315
+ deduped.push(ev);
316
+ prevSeq = ev.seq;
317
+ }
318
+ for (let attempt = 1; attempt <= this.cfg.maxRetries; attempt++) {
319
+ try {
320
+ const result = await this.doPost(deduped);
321
+ // Persist the lastSyncedSeq marker so a crash before the next
322
+ // flush still rediscovers the high-water-mark on restart.
323
+ if (result.lastSeq > this.highestSyncedSeq) {
324
+ this.highestSyncedSeq = result.lastSeq;
325
+ try {
326
+ writeSyncState(this.cfg.sessionId, this.highestSyncedSeq, this.cfg.stateDir);
327
+ }
328
+ catch {
329
+ // Best effort — a marker write failure does not invalidate
330
+ // the on-server data, only the resume hint.
331
+ }
332
+ }
333
+ return result;
334
+ }
335
+ catch (err) {
336
+ if (attempt === this.cfg.maxRetries) {
337
+ // Final surrender — drop the batch silently. Local NDJSON is
338
+ // authoritative; the next session resume can replay the gap.
339
+ return null;
340
+ }
341
+ await sleep(nextBackoffMs(attempt, this.cfg.debounceMs));
342
+ }
343
+ }
344
+ return null;
345
+ }
346
+ async doPost(events) {
347
+ const url = `${this.cfg.apiUrl}/api/pugi/sessions/${encodeURIComponent(this.cfg.sessionId)}/events`;
348
+ const res = await this.cfg.fetchImpl(url, {
349
+ method: 'POST',
350
+ headers: {
351
+ authorization: `Bearer ${this.cfg.apiKey}`,
352
+ 'content-type': 'application/json',
353
+ accept: 'application/json',
354
+ },
355
+ body: JSON.stringify({ events }),
356
+ });
357
+ if (!res.ok) {
358
+ throw new Error(`dual-write POST ${url} returned ${res.status} ${res.statusText}`);
359
+ }
360
+ const body = (await res.json());
361
+ return {
362
+ persistedCount: body.persistedCount ?? 0,
363
+ duplicateCount: body.duplicateCount ?? 0,
364
+ lastSeq: body.lastSeq ?? 0,
365
+ };
366
+ }
367
+ }
368
+ /** Validate a kind string against the Phase-1 closed set. */
369
+ export function isValidPhase1Kind(value) {
370
+ return PUGI_SESSION_EVENT_KINDS.includes(value);
371
+ }
372
+ /* ------------------------------------------------------------------ */
373
+ /* Kind remap (CLI broad set -> Phase-1 closed set) */
374
+ /* ------------------------------------------------------------------ */
375
+ /**
376
+ * Map the CLI's broader NDJSON kind set to the Phase-1 server-side set.
377
+ * Returns `null` when the kind has no Phase-1 representation — the
378
+ * caller should drop that event from the dual-write (the local NDJSON
379
+ * still carries it for offline analysis).
380
+ *
381
+ * The CLI's kind set lives in
382
+ * `apps/pugi-cli/src/core/repl/store/types.ts`:
383
+ *
384
+ * 'user' -> 'turn.user'
385
+ * 'persona' -> 'turn.assistant'
386
+ * 'system' -> 'system'
387
+ * 'tool.start' -> 'tool.call'
388
+ * 'tool.result' -> 'tool.result'
389
+ * 'agent.spawned' -> 'dispatch.start'
390
+ * 'agent.completed'-> 'dispatch.end'
391
+ * 'compaction' -> 'compact.boundary'
392
+ * 'rewind-marker' -> null (Phase-1 has no analog yet)
393
+ */
394
+ export function cliKindToPhase1(cliKind) {
395
+ switch (cliKind) {
396
+ case 'user':
397
+ return 'turn.user';
398
+ case 'persona':
399
+ return 'turn.assistant';
400
+ case 'system':
401
+ return 'system';
402
+ case 'tool.start':
403
+ return 'tool.call';
404
+ case 'tool.result':
405
+ return 'tool.result';
406
+ case 'agent.spawned':
407
+ return 'dispatch.start';
408
+ case 'agent.completed':
409
+ return 'dispatch.end';
410
+ case 'compaction':
411
+ return 'compact.boundary';
412
+ default:
413
+ return null;
414
+ }
415
+ }
416
+ //# sourceMappingURL=dual-write.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Phase-1 event kind closed set — mirror of
3
+ * `apps/admin-api/src/pugi-session-events/pugi-session-events.types.ts`
4
+ * PUGI_SESSION_EVENT_KINDS.
5
+ *
6
+ * Kept as a separate file so the CLI does not import from admin-api
7
+ * (would drag the whole Nest graph). The TWO lists must stay in
8
+ * lockstep — a new kind requires editing both files.
9
+ */
10
+ export const PUGI_SESSION_EVENT_KINDS = [
11
+ 'turn.user',
12
+ 'turn.assistant',
13
+ 'tool.call',
14
+ 'tool.result',
15
+ 'dispatch.start',
16
+ 'dispatch.end',
17
+ 'compact.boundary',
18
+ 'system',
19
+ ];
20
+ //# sourceMappingURL=phase1-kinds.js.map
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Pugi memory sync queue (ADR-0063 Day 4).
3
+ *
4
+ * Local pending-write queue for `pugi memory` commands when the
5
+ * operator is offline or the admin-api is unreachable. Each pending
6
+ * mutation lands on disk as one JSONL line; `pugi memory sync` reads
7
+ * the queue, fires them to the admin-api in order, and rewrites the
8
+ * file with only the entries that still failed.
9
+ *
10
+ * Storage:
11
+ *
12
+ * ~/.pugi/memory-queue.jsonl (mode 0600)
13
+ *
14
+ * Each line is a fully-typed `PendingMemoryOperation` envelope. The
15
+ * envelope is forward-compatible: an older CLI reading a JSONL file
16
+ * written by a newer CLI silently skips lines whose `op` field is
17
+ * not in its known set (so a partial-rollback scenario doesn't crash
18
+ * the queue).
19
+ *
20
+ * Design intent:
21
+ * - Append-only on disk for the hot path (`pugi memory write` /
22
+ * `pugi memory forget` queue when offline). Rewrites only on
23
+ * successful sync.
24
+ * - One file per operator (PUGI_HOME-aware). Queue is local to the
25
+ * machine — no cross-host coordination. Multi-device sync is
26
+ * deferred to Phase 6 (server-side outbox).
27
+ * - No fsync / atomic rename ceremony in v1 — best effort. The
28
+ * queue is a convenience surface, not a durability primitive;
29
+ * the source of truth is the admin-api row.
30
+ */
31
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
32
+ import { homedir } from 'node:os';
33
+ import { dirname, resolve } from 'node:path';
34
+ import { z } from 'zod';
35
+ /** Six canonical kinds — must mirror `apps/admin-api/src/persona-memory/persona-memory.types.ts`. */
36
+ export const PERSONA_MEMORY_KINDS = [
37
+ 'pattern',
38
+ 'preference',
39
+ 'architecture',
40
+ 'bug',
41
+ 'workflow',
42
+ 'fact',
43
+ ];
44
+ const writeOpSchema = z.object({
45
+ op: z.literal('write'),
46
+ enqueuedAt: z.string().datetime(),
47
+ personaSlug: z.string().min(1).max(64),
48
+ kind: z.enum(PERSONA_MEMORY_KINDS),
49
+ content: z.string().min(1).max(4000),
50
+ forgetAfter: z.string().datetime().nullable().optional(),
51
+ });
52
+ const forgetOpSchema = z.object({
53
+ op: z.literal('forget'),
54
+ enqueuedAt: z.string().datetime(),
55
+ id: z.string().min(1),
56
+ });
57
+ const pendingMemoryOpSchema = z.discriminatedUnion('op', [
58
+ writeOpSchema,
59
+ forgetOpSchema,
60
+ ]);
61
+ /** Default storage path. Override via `PUGI_HOME` for tests / multi-account. */
62
+ export function defaultQueuePath() {
63
+ const root = process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
64
+ return resolve(root, 'memory-queue.jsonl');
65
+ }
66
+ /**
67
+ * Append one pending operation to the queue file. Creates the parent
68
+ * directory + file with mode 0600 if missing. Pure-disk, no network.
69
+ *
70
+ * Returns the count of pending ops after the append (1-based) so the
71
+ * CLI command can render "queued (3 pending) — run `pugi memory sync`".
72
+ */
73
+ export function enqueueMemoryOp(op, pathOverride) {
74
+ const fullOp = {
75
+ ...op,
76
+ enqueuedAt: new Date().toISOString(),
77
+ };
78
+ pendingMemoryOpSchema.parse(fullOp);
79
+ const queuePath = pathOverride ?? defaultQueuePath();
80
+ ensureQueueFile(queuePath);
81
+ const existing = readFileSync(queuePath, 'utf-8');
82
+ const line = `${JSON.stringify(fullOp)}\n`;
83
+ writeFileSync(queuePath, `${existing}${line}`, { encoding: 'utf-8', mode: 0o600 });
84
+ // Best-effort chmod (in case the file existed already at the wrong mode).
85
+ try {
86
+ chmodSync(queuePath, 0o600);
87
+ }
88
+ catch {
89
+ // ignore — the file was just written above, mode might be platform-dependent.
90
+ }
91
+ return countPending(queuePath);
92
+ }
93
+ /** Read the queue file and return parsed entries. Skips unknown / malformed lines. */
94
+ export function readMemoryQueue(pathOverride) {
95
+ const queuePath = pathOverride ?? defaultQueuePath();
96
+ if (!existsSync(queuePath))
97
+ return [];
98
+ const raw = readFileSync(queuePath, 'utf-8');
99
+ const out = [];
100
+ for (const line of raw.split(/\r?\n/)) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed)
103
+ continue;
104
+ try {
105
+ const parsed = pendingMemoryOpSchema.parse(JSON.parse(trimmed));
106
+ out.push(parsed);
107
+ }
108
+ catch {
109
+ // forward-compat: a future op kind we don't recognise should not
110
+ // crash the queue; just drop the line during this read.
111
+ continue;
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+ /** Rewrite the queue file with `remaining` entries only. Empty array clears the file. */
117
+ export function rewriteMemoryQueue(remaining, pathOverride) {
118
+ const queuePath = pathOverride ?? defaultQueuePath();
119
+ ensureQueueFile(queuePath);
120
+ if (remaining.length === 0) {
121
+ writeFileSync(queuePath, '', { encoding: 'utf-8', mode: 0o600 });
122
+ return;
123
+ }
124
+ const body = remaining.map((op) => JSON.stringify(op)).join('\n') + '\n';
125
+ writeFileSync(queuePath, body, { encoding: 'utf-8', mode: 0o600 });
126
+ }
127
+ /** Count pending ops without re-parsing every line individually for the typed shape. */
128
+ export function countPending(pathOverride) {
129
+ const queuePath = pathOverride ?? defaultQueuePath();
130
+ if (!existsSync(queuePath))
131
+ return 0;
132
+ const raw = readFileSync(queuePath, 'utf-8');
133
+ let n = 0;
134
+ for (const line of raw.split(/\r?\n/)) {
135
+ if (line.trim().length > 0)
136
+ n++;
137
+ }
138
+ return n;
139
+ }
140
+ /** Quick predicate — was anything ever queued? */
141
+ export function hasPendingOps(pathOverride) {
142
+ return countPending(pathOverride) > 0;
143
+ }
144
+ function ensureQueueFile(queuePath) {
145
+ const dir = dirname(queuePath);
146
+ if (!existsSync(dir))
147
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
148
+ if (!existsSync(queuePath)) {
149
+ writeFileSync(queuePath, '', { encoding: 'utf-8', mode: 0o600 });
150
+ try {
151
+ chmodSync(queuePath, 0o600);
152
+ }
153
+ catch {
154
+ // ignore
155
+ }
156
+ }
157
+ }
158
+ //# sourceMappingURL=queue.js.map