@jmoyers/harness 0.1.10 → 0.1.20

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 (239) hide show
  1. package/README.md +31 -35
  2. package/package.json +31 -11
  3. package/packages/harness-ai/src/anthropic-protocol.ts +68 -68
  4. package/packages/harness-ai/src/stream-text.ts +13 -91
  5. package/packages/harness-ui/src/frame-primitives.ts +158 -0
  6. package/packages/harness-ui/src/index.ts +18 -0
  7. package/packages/harness-ui/src/interaction/conversation-input-forwarder.ts +221 -0
  8. package/packages/harness-ui/src/interaction/conversation-selection-input.ts +213 -0
  9. package/packages/harness-ui/src/interaction/global-shortcut-input.ts +172 -0
  10. package/{src/ui → packages/harness-ui/src/interaction}/input-preflight.ts +10 -12
  11. package/{src/ui → packages/harness-ui/src/interaction}/input-token-router.ts +120 -69
  12. package/packages/harness-ui/src/interaction/input.ts +420 -0
  13. package/packages/harness-ui/src/interaction/left-nav-input.ts +166 -0
  14. package/{src/ui → packages/harness-ui/src/interaction}/main-pane-pointer-input.ts +91 -23
  15. package/{src/ui → packages/harness-ui/src/interaction}/pointer-routing-input.ts +112 -48
  16. package/packages/harness-ui/src/interaction/rail-pointer-input.ts +62 -0
  17. package/packages/harness-ui/src/interaction/repository-fold-input.ts +118 -0
  18. package/packages/harness-ui/src/kit.ts +476 -0
  19. package/packages/harness-ui/src/layout.ts +238 -0
  20. package/{src/ui/modals/manager.ts → packages/harness-ui/src/modal-manager.ts} +94 -64
  21. package/{src/ui → packages/harness-ui/src}/screen.ts +53 -26
  22. package/packages/harness-ui/src/surface.ts +252 -0
  23. package/packages/harness-ui/src/text-layout.ts +210 -0
  24. package/packages/nim-core/src/contracts.ts +239 -0
  25. package/packages/nim-core/src/event-store.ts +299 -0
  26. package/packages/nim-core/src/events.ts +53 -0
  27. package/packages/nim-core/src/index.ts +9 -0
  28. package/packages/nim-core/src/provider-router.ts +129 -0
  29. package/packages/nim-core/src/providers/anthropic-driver.ts +291 -0
  30. package/packages/nim-core/src/runtime-factory.ts +49 -0
  31. package/packages/nim-core/src/runtime.ts +1797 -0
  32. package/packages/nim-core/src/session-store.ts +516 -0
  33. package/packages/nim-core/src/telemetry.ts +48 -0
  34. package/packages/nim-test-tui/src/index.ts +150 -0
  35. package/packages/nim-ui-core/src/index.ts +1 -0
  36. package/packages/nim-ui-core/src/projection.ts +87 -0
  37. package/scripts/codex-live-mux-runtime.ts +2 -3721
  38. package/scripts/control-plane-daemon.ts +24 -2
  39. package/scripts/harness-bin.js +5 -0
  40. package/scripts/harness-commands.ts +300 -0
  41. package/scripts/harness-runtime.ts +82 -0
  42. package/scripts/harness.ts +33 -3007
  43. package/scripts/nim-tui-smoke.ts +748 -0
  44. package/src/cli/auth/runtime.ts +948 -0
  45. package/src/cli/default-gateway-pointer.ts +193 -0
  46. package/src/cli/gateway/runtime.ts +1872 -0
  47. package/src/cli/parsing/flags.ts +23 -0
  48. package/src/cli/parsing/session.ts +42 -0
  49. package/src/cli/runtime/context.ts +193 -0
  50. package/src/cli/runtime-app/application.ts +392 -0
  51. package/src/cli/runtime-infra/gateway-control.ts +729 -0
  52. package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
  53. package/src/cli/workflows/runtime.ts +965 -0
  54. package/src/clients/tui/left-rail-interactions.ts +519 -0
  55. package/src/clients/tui/main-pane-interactions.ts +509 -0
  56. package/src/clients/tui/modal-input-routing.ts +71 -0
  57. package/src/clients/tui/render-snapshot-adapter.ts +88 -0
  58. package/src/clients/web/synced-selectors.ts +132 -0
  59. package/src/codex/live-session.ts +82 -29
  60. package/src/config/config-core.ts +361 -10
  61. package/src/config/harness-paths.ts +4 -7
  62. package/src/config/harness-runtime-migration.ts +142 -19
  63. package/src/config/harness.config.template.jsonc +33 -0
  64. package/src/config/secrets-core.ts +92 -4
  65. package/src/control-plane/agent-realtime-api.ts +82 -427
  66. package/src/control-plane/prompt/thread-title-namer.ts +49 -23
  67. package/src/control-plane/session-summary.ts +10 -81
  68. package/src/control-plane/status/reducer-base.ts +12 -12
  69. package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
  70. package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
  71. package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
  72. package/src/control-plane/stream-client.ts +12 -2
  73. package/src/control-plane/stream-command-parser.ts +83 -143
  74. package/src/control-plane/stream-protocol.ts +53 -37
  75. package/src/control-plane/stream-server-background.ts +18 -2
  76. package/src/control-plane/stream-server-command.ts +376 -69
  77. package/src/control-plane/stream-server-session-runtime.ts +3 -2
  78. package/src/control-plane/stream-server.ts +943 -80
  79. package/src/control-plane/stream-session-runtime-types.ts +41 -0
  80. package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
  81. package/src/core/state/observed-stream-cursor.ts +43 -0
  82. package/src/core/state/synced-observed-state.ts +273 -0
  83. package/src/core/store/harness-synced-store.ts +81 -0
  84. package/src/diff/budget.ts +136 -0
  85. package/src/diff/build.ts +289 -0
  86. package/src/diff/chunker.ts +146 -0
  87. package/src/diff/git-invoke.ts +315 -0
  88. package/src/diff/git-parse.ts +472 -0
  89. package/src/diff/hash.ts +70 -0
  90. package/src/diff/index.ts +24 -0
  91. package/src/diff/normalize.ts +134 -0
  92. package/src/diff/types.ts +178 -0
  93. package/src/diff-ui/args.ts +346 -0
  94. package/src/diff-ui/commands.ts +123 -0
  95. package/src/diff-ui/finder.ts +94 -0
  96. package/src/diff-ui/highlight.ts +127 -0
  97. package/src/diff-ui/index.ts +2 -0
  98. package/src/diff-ui/model.ts +141 -0
  99. package/src/diff-ui/pager.ts +412 -0
  100. package/src/diff-ui/render.ts +337 -0
  101. package/src/diff-ui/runtime.ts +379 -0
  102. package/src/diff-ui/state.ts +224 -0
  103. package/src/diff-ui/types.ts +236 -0
  104. package/src/domain/conversations.ts +11 -7
  105. package/src/domain/workspace.ts +76 -4
  106. package/src/mux/control-plane-op-queue.ts +93 -7
  107. package/src/mux/conversation-rail.ts +28 -71
  108. package/src/mux/dual-pane-core.ts +13 -13
  109. package/src/mux/harness-core-ui.ts +313 -42
  110. package/src/mux/input-shortcuts.ts +22 -112
  111. package/src/mux/keybinding-catalog.ts +340 -0
  112. package/src/mux/keybinding-registry.ts +103 -0
  113. package/src/mux/live-mux/command-menu-open-in.ts +280 -0
  114. package/src/mux/live-mux/command-menu.ts +167 -4
  115. package/src/mux/live-mux/conversation-state.ts +13 -0
  116. package/src/mux/live-mux/directory-resolution.ts +1 -1
  117. package/src/mux/live-mux/git-parsing.ts +16 -0
  118. package/src/mux/live-mux/git-snapshot.ts +33 -2
  119. package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
  120. package/src/mux/live-mux/home-pane-drop.ts +1 -1
  121. package/src/mux/live-mux/home-pane-pointer.ts +10 -0
  122. package/src/mux/live-mux/input-forwarding.ts +59 -2
  123. package/src/mux/live-mux/left-nav-activation.ts +124 -7
  124. package/src/mux/live-mux/left-nav.ts +35 -0
  125. package/src/mux/live-mux/link-click.ts +292 -0
  126. package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
  127. package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
  128. package/src/mux/live-mux/modal-input-reducers.ts +106 -8
  129. package/src/mux/live-mux/modal-overlays.ts +210 -31
  130. package/src/mux/live-mux/modal-pointer.ts +3 -7
  131. package/src/mux/live-mux/modal-prompt-handlers.ts +107 -1
  132. package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
  133. package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
  134. package/src/mux/live-mux/pointer-routing.ts +5 -2
  135. package/src/mux/live-mux/project-pane-pointer.ts +8 -0
  136. package/src/mux/live-mux/rail-layout.ts +33 -30
  137. package/src/mux/live-mux/release-notes.ts +383 -0
  138. package/src/mux/live-mux/render-trace-analysis.ts +52 -7
  139. package/src/mux/live-mux/repository-folding.ts +3 -0
  140. package/src/mux/live-mux/selection.ts +0 -4
  141. package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
  142. package/src/mux/project-pane-github-review.ts +271 -0
  143. package/src/mux/render-frame.ts +4 -0
  144. package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
  145. package/src/mux/task-composer.ts +21 -14
  146. package/src/mux/task-focused-pane.ts +118 -117
  147. package/src/mux/task-screen-keybindings.ts +19 -82
  148. package/src/mux/workspace-rail-model.ts +270 -104
  149. package/src/mux/workspace-rail.ts +45 -22
  150. package/src/pty/session-broker.ts +1 -1
  151. package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
  152. package/src/services/control-plane.ts +50 -32
  153. package/src/services/conversation-lifecycle.ts +118 -87
  154. package/src/services/conversation-startup-hydration.ts +20 -12
  155. package/src/services/directory-hydration.ts +21 -16
  156. package/src/services/event-persistence.ts +7 -0
  157. package/src/services/left-rail-pointer-handler.ts +329 -0
  158. package/src/services/mux-ui-state-persistence.ts +5 -1
  159. package/src/services/recording.ts +34 -26
  160. package/src/services/runtime-command-menu-agent-tools.ts +1 -1
  161. package/src/services/runtime-control-actions.ts +79 -61
  162. package/src/services/runtime-control-plane-ops.ts +122 -83
  163. package/src/services/runtime-conversation-actions.ts +40 -26
  164. package/src/services/runtime-conversation-activation.ts +82 -30
  165. package/src/services/runtime-conversation-starter.ts +80 -48
  166. package/src/services/runtime-conversation-title-edit.ts +91 -80
  167. package/src/services/runtime-envelope-handler.ts +107 -105
  168. package/src/services/runtime-git-state.ts +42 -29
  169. package/src/services/runtime-layout-resize.ts +3 -1
  170. package/src/services/runtime-left-rail-render.ts +99 -63
  171. package/src/services/runtime-nim-cli-session.ts +438 -0
  172. package/src/services/runtime-nim-session.ts +705 -0
  173. package/src/services/runtime-nim-tool-bridge.ts +141 -0
  174. package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
  175. package/src/services/runtime-process-wiring.ts +29 -36
  176. package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
  177. package/src/services/runtime-render-flush.ts +63 -70
  178. package/src/services/runtime-render-lifecycle.ts +65 -64
  179. package/src/services/runtime-render-orchestrator.ts +55 -45
  180. package/src/services/runtime-render-pipeline.ts +106 -103
  181. package/src/services/runtime-render-state.ts +62 -49
  182. package/src/services/runtime-repository-actions.ts +97 -70
  183. package/src/services/runtime-right-pane-render.ts +80 -53
  184. package/src/services/runtime-shutdown.ts +38 -35
  185. package/src/services/runtime-stream-subscriptions.ts +35 -27
  186. package/src/services/runtime-task-composer-persistence.ts +71 -59
  187. package/src/services/runtime-task-composer-snapshot.ts +14 -0
  188. package/src/services/runtime-task-editor-actions.ts +46 -29
  189. package/src/services/runtime-task-pane-actions.ts +220 -134
  190. package/src/services/runtime-task-pane-shortcuts.ts +323 -123
  191. package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
  192. package/src/services/runtime-workspace-observed-events.ts +33 -184
  193. package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
  194. package/src/services/session-diagnostics-store.ts +217 -0
  195. package/src/services/startup-background-resume.ts +26 -21
  196. package/src/services/startup-orchestrator.ts +16 -13
  197. package/src/services/startup-paint-tracker.ts +29 -21
  198. package/src/services/startup-persisted-conversation-queue.ts +19 -13
  199. package/src/services/startup-settled-gate.ts +25 -15
  200. package/src/services/startup-shutdown.ts +18 -22
  201. package/src/services/startup-state-hydration.ts +44 -34
  202. package/src/services/startup-visibility.ts +12 -4
  203. package/src/services/task-pane-selection-actions.ts +89 -72
  204. package/src/services/task-planning-hydration.ts +24 -18
  205. package/src/services/task-planning-observed-events.ts +50 -52
  206. package/src/services/workspace-observed-events.ts +66 -63
  207. package/src/storage/storage-lifecycle-core.ts +438 -0
  208. package/src/store/control-plane-store-normalize.ts +33 -242
  209. package/src/store/control-plane-store-types.ts +1 -35
  210. package/src/store/control-plane-store.ts +396 -56
  211. package/src/store/event-store.ts +397 -3
  212. package/src/terminal/snapshot-oracle.ts +207 -94
  213. package/src/ui/mux-theme.ts +112 -8
  214. package/src/ui/panes/home-gridfire.ts +40 -31
  215. package/src/ui/panes/home.ts +10 -2
  216. package/src/ui/panes/nim.ts +315 -0
  217. package/src/mux/live-mux/actions-task.ts +0 -115
  218. package/src/mux/live-mux/left-rail-actions.ts +0 -118
  219. package/src/mux/live-mux/left-rail-conversation-click.ts +0 -82
  220. package/src/mux/live-mux/left-rail-pointer.ts +0 -74
  221. package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
  222. package/src/services/runtime-directory-actions.ts +0 -164
  223. package/src/services/runtime-input-pipeline.ts +0 -50
  224. package/src/services/runtime-input-router.ts +0 -189
  225. package/src/services/runtime-main-pane-input.ts +0 -230
  226. package/src/services/runtime-modal-input.ts +0 -119
  227. package/src/services/runtime-navigation-input.ts +0 -197
  228. package/src/services/runtime-rail-input.ts +0 -278
  229. package/src/services/runtime-task-pane.ts +0 -62
  230. package/src/services/runtime-workspace-actions.ts +0 -158
  231. package/src/ui/conversation-input-forwarder.ts +0 -114
  232. package/src/ui/conversation-selection-input.ts +0 -103
  233. package/src/ui/global-shortcut-input.ts +0 -89
  234. package/src/ui/input.ts +0 -238
  235. package/src/ui/kit.ts +0 -509
  236. package/src/ui/left-nav-input.ts +0 -80
  237. package/src/ui/left-rail-pointer-input.ts +0 -148
  238. package/src/ui/repository-fold-input.ts +0 -91
  239. package/src/ui/surface.ts +0 -224
@@ -0,0 +1,516 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname } from 'node:path';
4
+ import type { NimModelRef } from './contracts.ts';
5
+
6
+ export type NimPersistedSession = {
7
+ readonly sessionId: string;
8
+ readonly tenantId: string;
9
+ readonly userId: string;
10
+ readonly model: NimModelRef;
11
+ readonly lane: string;
12
+ readonly soulHash?: string;
13
+ readonly skillsSnapshotVersion?: number;
14
+ readonly eventSeq: number;
15
+ readonly lastRunId?: string;
16
+ readonly followups: readonly NimPersistedFollowUp[];
17
+ };
18
+
19
+ export type NimPersistedIdempotency = {
20
+ readonly idempotencyKey: string;
21
+ readonly runId: string;
22
+ };
23
+
24
+ export type NimPersistedFollowUp = {
25
+ readonly queueId: string;
26
+ readonly text: string;
27
+ readonly priority: 'normal' | 'high';
28
+ readonly dedupeKey: string;
29
+ };
30
+
31
+ export interface NimSessionStore {
32
+ upsertSession(session: NimPersistedSession): void;
33
+ getSession(sessionId: string): NimPersistedSession | undefined;
34
+ listSessions(tenantId: string, userId: string): readonly NimPersistedSession[];
35
+ upsertIdempotency(sessionId: string, idempotencyKey: string, runId: string): void;
36
+ getRunIdByIdempotency(sessionId: string, idempotencyKey: string): string | undefined;
37
+ listIdempotency(sessionId: string): readonly NimPersistedIdempotency[];
38
+ }
39
+
40
+ export class InMemoryNimSessionStore implements NimSessionStore {
41
+ private sessions = new Map<string, NimPersistedSession>();
42
+ private idempotencyBySession = new Map<string, Map<string, string>>();
43
+
44
+ public upsertSession(session: NimPersistedSession): void {
45
+ this.sessions.set(session.sessionId, session);
46
+ }
47
+
48
+ public getSession(sessionId: string): NimPersistedSession | undefined {
49
+ return this.sessions.get(sessionId);
50
+ }
51
+
52
+ public listSessions(tenantId: string, userId: string): readonly NimPersistedSession[] {
53
+ return Array.from(this.sessions.values()).filter(
54
+ (session) => session.tenantId === tenantId && session.userId === userId,
55
+ );
56
+ }
57
+
58
+ public upsertIdempotency(sessionId: string, idempotencyKey: string, runId: string): void {
59
+ let map = this.idempotencyBySession.get(sessionId);
60
+ if (map === undefined) {
61
+ map = new Map<string, string>();
62
+ this.idempotencyBySession.set(sessionId, map);
63
+ }
64
+ if (map.has(idempotencyKey)) {
65
+ return;
66
+ }
67
+ map.set(idempotencyKey, runId);
68
+ }
69
+
70
+ public getRunIdByIdempotency(sessionId: string, idempotencyKey: string): string | undefined {
71
+ return this.idempotencyBySession.get(sessionId)?.get(idempotencyKey);
72
+ }
73
+
74
+ public listIdempotency(sessionId: string): readonly NimPersistedIdempotency[] {
75
+ const map = this.idempotencyBySession.get(sessionId);
76
+ if (map === undefined) {
77
+ return [];
78
+ }
79
+ return Array.from(map.entries()).map(([idempotencyKey, runId]) => ({
80
+ idempotencyKey,
81
+ runId,
82
+ }));
83
+ }
84
+ }
85
+
86
+ interface StatementLike {
87
+ run: (...params: unknown[]) => unknown;
88
+ get: (...params: unknown[]) => unknown;
89
+ all: (...params: unknown[]) => unknown[];
90
+ }
91
+
92
+ class WrappedStatement {
93
+ private readonly statement: StatementLike;
94
+
95
+ public constructor(statement: StatementLike) {
96
+ this.statement = statement;
97
+ }
98
+
99
+ public run(...params: unknown[]): unknown {
100
+ return this.statement.run(...params);
101
+ }
102
+
103
+ public get(...params: unknown[]): unknown {
104
+ const value = this.statement.get(...params);
105
+ return value === null ? undefined : value;
106
+ }
107
+
108
+ public all(...params: unknown[]): unknown[] {
109
+ return this.statement.all(...params);
110
+ }
111
+ }
112
+
113
+ interface SqliteDatabaseLike {
114
+ close: () => void;
115
+ exec: (sql: string) => unknown;
116
+ prepare: (sql: string) => StatementLike;
117
+ }
118
+
119
+ type BunSqliteModule = {
120
+ Database: new (path: string) => SqliteDatabaseLike;
121
+ };
122
+
123
+ interface SqliteRuntime {
124
+ readonly bunVersion: string | undefined;
125
+ readonly loadModule: (specifier: 'bun:sqlite') => unknown;
126
+ }
127
+
128
+ const require = createRequire(import.meta.url);
129
+ const defaultRuntime: SqliteRuntime = {
130
+ bunVersion: process.versions.bun,
131
+ loadModule: (specifier) => require(specifier) as unknown,
132
+ };
133
+
134
+ function createDatabaseForRuntime(
135
+ path: string,
136
+ runtime: SqliteRuntime = defaultRuntime,
137
+ ): SqliteDatabaseLike {
138
+ if (runtime.bunVersion === undefined) {
139
+ throw new Error('bun runtime is required for sqlite access');
140
+ }
141
+ const module = runtime.loadModule('bun:sqlite') as BunSqliteModule;
142
+ return new module.Database(path);
143
+ }
144
+
145
+ class DatabaseSync {
146
+ private readonly database: SqliteDatabaseLike;
147
+
148
+ public constructor(path: string, runtime: SqliteRuntime = defaultRuntime) {
149
+ this.database = createDatabaseForRuntime(path, runtime);
150
+ }
151
+
152
+ public close(): void {
153
+ this.database.close();
154
+ }
155
+
156
+ public exec(sql: string): void {
157
+ this.database.exec(sql);
158
+ }
159
+
160
+ public prepare(sql: string): WrappedStatement {
161
+ return new WrappedStatement(this.database.prepare(sql));
162
+ }
163
+ }
164
+
165
+ const NIM_SESSION_STORE_SCHEMA_VERSION = 2;
166
+
167
+ function asRecord(value: unknown): Record<string, unknown> {
168
+ if (typeof value !== 'object' || value === null) {
169
+ throw new Error('expected sqlite row object');
170
+ }
171
+ return value as Record<string, unknown>;
172
+ }
173
+
174
+ function asString(value: unknown, field: string): string {
175
+ if (typeof value !== 'string') {
176
+ throw new Error(`expected string for ${field}`);
177
+ }
178
+ return value;
179
+ }
180
+
181
+ function asFollowupPriority(value: unknown, field: string): 'normal' | 'high' {
182
+ if (value === 'normal' || value === 'high') {
183
+ return value;
184
+ }
185
+ throw new Error(`expected follow-up priority for ${field}`);
186
+ }
187
+
188
+ function asStringOrUndefined(value: unknown, field: string): string | undefined {
189
+ if (value === null || value === undefined) {
190
+ return undefined;
191
+ }
192
+ return asString(value, field);
193
+ }
194
+
195
+ function asNonNegativeInteger(value: unknown, field: string): number {
196
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
197
+ throw new Error(`expected non-negative integer for ${field}`);
198
+ }
199
+ return value;
200
+ }
201
+
202
+ function asNonNegativeIntegerOrUndefined(value: unknown, field: string): number | undefined {
203
+ if (value === null || value === undefined) {
204
+ return undefined;
205
+ }
206
+ return asNonNegativeInteger(value, field);
207
+ }
208
+
209
+ function parseFollowupsJson(value: unknown): readonly NimPersistedFollowUp[] {
210
+ const json = asString(value, 'followups_json');
211
+ let parsed: unknown;
212
+ try {
213
+ parsed = JSON.parse(json);
214
+ } catch {
215
+ throw new Error('invalid followups_json');
216
+ }
217
+ if (!Array.isArray(parsed)) {
218
+ throw new Error('expected followups_json array');
219
+ }
220
+ return parsed.map((item, index) => {
221
+ const row = asRecord(item);
222
+ return {
223
+ queueId: asString(row.queueId, `followups_json[${String(index)}].queueId`),
224
+ text: asString(row.text, `followups_json[${String(index)}].text`),
225
+ priority: asFollowupPriority(row.priority, `followups_json[${String(index)}].priority`),
226
+ dedupeKey: asString(row.dedupeKey, `followups_json[${String(index)}].dedupeKey`),
227
+ };
228
+ });
229
+ }
230
+
231
+ function parsePersistedSessionRow(row: unknown): NimPersistedSession {
232
+ const value = asRecord(row);
233
+ const soulHash = asStringOrUndefined(value.soul_hash, 'soul_hash');
234
+ const skillsSnapshotVersion = asNonNegativeIntegerOrUndefined(
235
+ value.skills_snapshot_version,
236
+ 'skills_snapshot_version',
237
+ );
238
+ const lastRunId = asStringOrUndefined(value.last_run_id, 'last_run_id');
239
+ const followups = parseFollowupsJson(value.followups_json);
240
+ return {
241
+ sessionId: asString(value.session_id, 'session_id'),
242
+ tenantId: asString(value.tenant_id, 'tenant_id'),
243
+ userId: asString(value.user_id, 'user_id'),
244
+ model: asString(value.model, 'model') as NimModelRef,
245
+ lane: asString(value.lane, 'lane'),
246
+ ...(soulHash !== undefined ? { soulHash } : {}),
247
+ ...(skillsSnapshotVersion !== undefined ? { skillsSnapshotVersion } : {}),
248
+ eventSeq: asNonNegativeInteger(value.event_seq, 'event_seq'),
249
+ ...(lastRunId !== undefined ? { lastRunId } : {}),
250
+ followups,
251
+ };
252
+ }
253
+
254
+ function parseIdempotencyRow(row: unknown): NimPersistedIdempotency {
255
+ const value = asRecord(row);
256
+ return {
257
+ idempotencyKey: asString(value.idempotency_key, 'idempotency_key'),
258
+ runId: asString(value.run_id, 'run_id'),
259
+ };
260
+ }
261
+
262
+ function preparePath(filePath: string): string {
263
+ if (filePath === ':memory:') {
264
+ return filePath;
265
+ }
266
+ mkdirSync(dirname(filePath), { recursive: true });
267
+ return filePath;
268
+ }
269
+
270
+ export class NimSqliteSessionStore implements NimSessionStore {
271
+ private readonly db: DatabaseSync;
272
+
273
+ public constructor(filePath = ':memory:') {
274
+ this.db = new DatabaseSync(preparePath(filePath));
275
+ this.configureConnection();
276
+ this.initializeSchema();
277
+ }
278
+
279
+ public close(): void {
280
+ this.db.close();
281
+ }
282
+
283
+ public upsertSession(session: NimPersistedSession): void {
284
+ this.db
285
+ .prepare(
286
+ `
287
+ INSERT INTO nim_sessions (
288
+ session_id,
289
+ tenant_id,
290
+ user_id,
291
+ model,
292
+ lane,
293
+ soul_hash,
294
+ skills_snapshot_version,
295
+ event_seq,
296
+ last_run_id,
297
+ followups_json
298
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
299
+ ON CONFLICT(session_id) DO UPDATE SET
300
+ tenant_id = excluded.tenant_id,
301
+ user_id = excluded.user_id,
302
+ model = excluded.model,
303
+ lane = excluded.lane,
304
+ soul_hash = excluded.soul_hash,
305
+ skills_snapshot_version = excluded.skills_snapshot_version,
306
+ event_seq = excluded.event_seq,
307
+ last_run_id = excluded.last_run_id,
308
+ followups_json = excluded.followups_json
309
+ `,
310
+ )
311
+ .run(
312
+ session.sessionId,
313
+ session.tenantId,
314
+ session.userId,
315
+ session.model,
316
+ session.lane,
317
+ session.soulHash ?? null,
318
+ session.skillsSnapshotVersion ?? null,
319
+ session.eventSeq,
320
+ session.lastRunId ?? null,
321
+ JSON.stringify(session.followups),
322
+ );
323
+ }
324
+
325
+ public getSession(sessionId: string): NimPersistedSession | undefined {
326
+ const row = this.db
327
+ .prepare(
328
+ `
329
+ SELECT
330
+ session_id,
331
+ tenant_id,
332
+ user_id,
333
+ model,
334
+ lane,
335
+ soul_hash,
336
+ skills_snapshot_version,
337
+ event_seq,
338
+ last_run_id,
339
+ followups_json
340
+ FROM nim_sessions
341
+ WHERE session_id = ?
342
+ LIMIT 1
343
+ `,
344
+ )
345
+ .get(sessionId);
346
+ if (row === undefined) {
347
+ return undefined;
348
+ }
349
+ return parsePersistedSessionRow(row);
350
+ }
351
+
352
+ public listSessions(tenantId: string, userId: string): readonly NimPersistedSession[] {
353
+ const rows = this.db
354
+ .prepare(
355
+ `
356
+ SELECT
357
+ session_id,
358
+ tenant_id,
359
+ user_id,
360
+ model,
361
+ lane,
362
+ soul_hash,
363
+ skills_snapshot_version,
364
+ event_seq,
365
+ last_run_id,
366
+ followups_json
367
+ FROM nim_sessions
368
+ WHERE tenant_id = ? AND user_id = ?
369
+ ORDER BY session_id ASC
370
+ `,
371
+ )
372
+ .all(tenantId, userId);
373
+ return rows.map((row) => parsePersistedSessionRow(row));
374
+ }
375
+
376
+ public upsertIdempotency(sessionId: string, idempotencyKey: string, runId: string): void {
377
+ this.db
378
+ .prepare(
379
+ `
380
+ INSERT OR IGNORE INTO nim_session_idempotency (
381
+ session_id,
382
+ idempotency_key,
383
+ run_id
384
+ ) VALUES (?, ?, ?)
385
+ `,
386
+ )
387
+ .run(sessionId, idempotencyKey, runId);
388
+ }
389
+
390
+ public getRunIdByIdempotency(sessionId: string, idempotencyKey: string): string | undefined {
391
+ const row = this.db
392
+ .prepare(
393
+ `
394
+ SELECT run_id
395
+ FROM nim_session_idempotency
396
+ WHERE session_id = ? AND idempotency_key = ?
397
+ LIMIT 1
398
+ `,
399
+ )
400
+ .get(sessionId, idempotencyKey);
401
+ if (row === undefined) {
402
+ return undefined;
403
+ }
404
+ return asString(asRecord(row).run_id, 'run_id');
405
+ }
406
+
407
+ public listIdempotency(sessionId: string): readonly NimPersistedIdempotency[] {
408
+ const rows = this.db
409
+ .prepare(
410
+ `
411
+ SELECT idempotency_key, run_id
412
+ FROM nim_session_idempotency
413
+ WHERE session_id = ?
414
+ ORDER BY idempotency_key ASC
415
+ `,
416
+ )
417
+ .all(sessionId);
418
+ return rows.map((row) => parseIdempotencyRow(row));
419
+ }
420
+
421
+ private configureConnection(): void {
422
+ this.db.exec('PRAGMA journal_mode = WAL;');
423
+ this.db.exec('PRAGMA synchronous = NORMAL;');
424
+ this.db.exec('PRAGMA busy_timeout = 5000;');
425
+ this.db.exec('PRAGMA foreign_keys = ON;');
426
+ }
427
+
428
+ private initializeSchema(): void {
429
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
430
+ try {
431
+ const currentVersion = this.readSchemaVersion();
432
+ if (currentVersion > NIM_SESSION_STORE_SCHEMA_VERSION) {
433
+ throw new Error(
434
+ `nim session store schema version ${String(currentVersion)} is newer than supported version ${String(NIM_SESSION_STORE_SCHEMA_VERSION)}`,
435
+ );
436
+ }
437
+ if (currentVersion < 1) {
438
+ this.applySchemaV1();
439
+ }
440
+ if (currentVersion < 2) {
441
+ this.applySchemaV2();
442
+ }
443
+ this.writeSchemaVersion(NIM_SESSION_STORE_SCHEMA_VERSION);
444
+ this.db.exec('COMMIT');
445
+ } catch (error) {
446
+ this.db.exec('ROLLBACK');
447
+ throw error;
448
+ }
449
+ }
450
+
451
+ private applySchemaV1(): void {
452
+ this.db.exec(`
453
+ CREATE TABLE IF NOT EXISTS nim_sessions (
454
+ session_id TEXT PRIMARY KEY,
455
+ tenant_id TEXT NOT NULL,
456
+ user_id TEXT NOT NULL,
457
+ model TEXT NOT NULL,
458
+ lane TEXT NOT NULL,
459
+ soul_hash TEXT,
460
+ skills_snapshot_version INTEGER,
461
+ event_seq INTEGER NOT NULL,
462
+ last_run_id TEXT
463
+ );
464
+ `);
465
+ this.db.exec(`
466
+ CREATE INDEX IF NOT EXISTS idx_nim_sessions_scope
467
+ ON nim_sessions (tenant_id, user_id, session_id);
468
+ `);
469
+ this.db.exec(`
470
+ CREATE TABLE IF NOT EXISTS nim_session_idempotency (
471
+ session_id TEXT NOT NULL,
472
+ idempotency_key TEXT NOT NULL,
473
+ run_id TEXT NOT NULL,
474
+ PRIMARY KEY (session_id, idempotency_key),
475
+ FOREIGN KEY (session_id) REFERENCES nim_sessions(session_id) ON DELETE CASCADE
476
+ );
477
+ `);
478
+ this.db.exec(`
479
+ CREATE INDEX IF NOT EXISTS idx_nim_session_idempotency_session
480
+ ON nim_session_idempotency (session_id, idempotency_key);
481
+ `);
482
+ }
483
+
484
+ private applySchemaV2(): void {
485
+ if (!this.tableHasColumn('nim_sessions', 'followups_json')) {
486
+ this.db.exec(`
487
+ ALTER TABLE nim_sessions
488
+ ADD COLUMN followups_json TEXT NOT NULL DEFAULT '[]';
489
+ `);
490
+ }
491
+ }
492
+
493
+ private readSchemaVersion(): number {
494
+ const row = this.db.prepare('PRAGMA user_version;').get();
495
+ if (row === undefined) {
496
+ throw new Error('failed to read nim session store schema version');
497
+ }
498
+ const version = asRecord(row).user_version;
499
+ if (typeof version !== 'number' || !Number.isInteger(version) || version < 0) {
500
+ throw new Error(`invalid nim session store schema version value: ${String(version)}`);
501
+ }
502
+ return version;
503
+ }
504
+
505
+ private writeSchemaVersion(version: number): void {
506
+ this.db.exec(`PRAGMA user_version = ${String(version)};`);
507
+ }
508
+
509
+ private tableHasColumn(tableName: string, columnName: string): boolean {
510
+ const rows = this.db.prepare(`PRAGMA table_info(${tableName});`).all();
511
+ return rows.some((row) => {
512
+ const name = asRecord(row).name;
513
+ return typeof name === 'string' && name === columnName;
514
+ });
515
+ }
516
+ }
@@ -0,0 +1,48 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import type { NimTelemetrySink } from './contracts.ts';
4
+ import { parseNimEventEnvelope, type NimEventEnvelope } from './events.ts';
5
+
6
+ export type NimJsonlTelemetrySinkInput = {
7
+ readonly filePath: string;
8
+ readonly mode?: 'append' | 'truncate';
9
+ };
10
+
11
+ export class NimJsonlTelemetrySink implements NimTelemetrySink {
12
+ public readonly name: string;
13
+ private readonly filePath: string;
14
+
15
+ public constructor(input: NimJsonlTelemetrySinkInput) {
16
+ this.filePath = input.filePath;
17
+ this.name = `jsonl:${this.filePath}`;
18
+ mkdirSync(dirname(this.filePath), { recursive: true });
19
+ if (input.mode !== 'append') {
20
+ writeFileSync(this.filePath, '', 'utf8');
21
+ }
22
+ }
23
+
24
+ public record(event: NimEventEnvelope): void {
25
+ appendFileSync(this.filePath, `${JSON.stringify(event)}\n`, 'utf8');
26
+ }
27
+ }
28
+
29
+ export function readNimJsonlTelemetry(filePath: string): NimEventEnvelope[] {
30
+ const content = readFileSync(filePath, 'utf8').trim();
31
+ if (content.length === 0) {
32
+ return [];
33
+ }
34
+ const lines = content.split('\n');
35
+ return lines.map((line, index) => {
36
+ let parsed: unknown;
37
+ try {
38
+ parsed = JSON.parse(line);
39
+ } catch {
40
+ throw new Error(`invalid Nim telemetry JSONL at line ${String(index + 1)}`);
41
+ }
42
+ try {
43
+ return parseNimEventEnvelope(parsed);
44
+ } catch {
45
+ throw new Error(`invalid Nim telemetry event envelope at line ${String(index + 1)}`);
46
+ }
47
+ });
48
+ }
@@ -0,0 +1,150 @@
1
+ import type { NimEventEnvelope } from '../../nim-core/src/events.ts';
2
+ import type { NimRuntime, NimUiEvent } from '../../nim-core/src/contracts.ts';
3
+ import { projectEventToUiEvents, type NimUiMode } from '../../nim-ui-core/src/projection.ts';
4
+
5
+ export type TestTuiFrame = {
6
+ readonly mode: NimUiMode;
7
+ readonly runId: string;
8
+ readonly lines: readonly string[];
9
+ readonly state: 'thinking' | 'tool-calling' | 'responding' | 'idle';
10
+ };
11
+
12
+ export type CollectNimTestTuiFrameInput = {
13
+ readonly runtime: NimRuntime;
14
+ readonly tenantId: string;
15
+ readonly sessionId: string;
16
+ readonly mode: NimUiMode;
17
+ readonly fromEventIdExclusive?: string;
18
+ readonly timeoutMs?: number;
19
+ };
20
+
21
+ export type CollectNimTestTuiFrameResult = {
22
+ readonly frame: TestTuiFrame;
23
+ readonly lastEventId?: string;
24
+ readonly projectedEventCount: number;
25
+ };
26
+
27
+ export class NimTestTuiController {
28
+ private mode: NimUiMode;
29
+ private runId: string;
30
+ private state: 'thinking' | 'tool-calling' | 'responding' | 'idle';
31
+ private lines: string[];
32
+ private pendingAssistantText: string;
33
+
34
+ public constructor(input: { mode: NimUiMode; runId: string }) {
35
+ this.mode = input.mode;
36
+ this.runId = input.runId;
37
+ this.state = 'idle';
38
+ this.lines = [];
39
+ this.pendingAssistantText = '';
40
+ }
41
+
42
+ public consume(event: NimEventEnvelope): readonly NimUiEvent[] {
43
+ if (event.run_id.length > 0) {
44
+ this.runId = event.run_id;
45
+ }
46
+ const projected = projectEventToUiEvents(event, this.mode);
47
+ for (const item of projected) {
48
+ if (item.type === 'assistant.state') {
49
+ this.state = item.state;
50
+ continue;
51
+ }
52
+ if (item.type === 'assistant.text.delta') {
53
+ this.pendingAssistantText += item.text;
54
+ continue;
55
+ }
56
+ if (item.type === 'assistant.text.message') {
57
+ this.pendingAssistantText = '';
58
+ this.lines.push(item.text);
59
+ continue;
60
+ }
61
+ if (item.type === 'tool.activity') {
62
+ this.lines.push(`[tool:${item.phase}] ${item.toolName}`);
63
+ continue;
64
+ }
65
+ if (item.type === 'system.notice') {
66
+ this.lines.push(`[notice] ${item.text}`);
67
+ }
68
+ }
69
+ return projected;
70
+ }
71
+
72
+ public snapshot(): TestTuiFrame {
73
+ const lines =
74
+ this.pendingAssistantText.length > 0
75
+ ? [...this.lines, this.pendingAssistantText]
76
+ : this.lines.slice();
77
+ return {
78
+ mode: this.mode,
79
+ runId: this.runId,
80
+ lines,
81
+ state: this.state,
82
+ };
83
+ }
84
+ }
85
+
86
+ export async function collectNimTestTuiFrame(
87
+ input: CollectNimTestTuiFrameInput,
88
+ ): Promise<CollectNimTestTuiFrameResult> {
89
+ const stream = input.runtime.streamEvents({
90
+ tenantId: input.tenantId,
91
+ sessionId: input.sessionId,
92
+ ...(input.fromEventIdExclusive !== undefined
93
+ ? { fromEventIdExclusive: input.fromEventIdExclusive }
94
+ : {}),
95
+ fidelity: 'semantic',
96
+ });
97
+ const iterator = stream[Symbol.asyncIterator]();
98
+ const controller = new NimTestTuiController({
99
+ mode: input.mode,
100
+ runId: input.sessionId,
101
+ });
102
+ const timeoutMs = input.timeoutMs ?? 5000;
103
+ const deadline = Date.now() + timeoutMs;
104
+ let sawActiveState = false;
105
+ let projectedEventCount = 0;
106
+ let lastEventId: string | undefined;
107
+ try {
108
+ while (Date.now() < deadline) {
109
+ const remaining = deadline - Date.now();
110
+ let timer: ReturnType<typeof setTimeout> | undefined;
111
+ const next = await Promise.race([
112
+ iterator.next(),
113
+ new Promise<never>((_, reject) => {
114
+ timer = setTimeout(() => {
115
+ reject(new Error('timed out waiting for Nim test TUI idle frame'));
116
+ }, remaining);
117
+ }),
118
+ ]).finally(() => {
119
+ if (timer !== undefined) {
120
+ clearTimeout(timer);
121
+ }
122
+ });
123
+ if (next.done) {
124
+ break;
125
+ }
126
+ lastEventId = next.value.event_id;
127
+ const projected = controller.consume(next.value);
128
+ projectedEventCount += projected.length;
129
+ for (const item of projected) {
130
+ if (item.type !== 'assistant.state') {
131
+ continue;
132
+ }
133
+ if (item.state !== 'idle') {
134
+ sawActiveState = true;
135
+ continue;
136
+ }
137
+ if (sawActiveState) {
138
+ return {
139
+ frame: controller.snapshot(),
140
+ ...(lastEventId !== undefined ? { lastEventId } : {}),
141
+ projectedEventCount,
142
+ };
143
+ }
144
+ }
145
+ }
146
+ throw new Error('timed out waiting for Nim test TUI idle frame');
147
+ } finally {
148
+ await iterator.return?.();
149
+ }
150
+ }
@@ -0,0 +1 @@
1
+ export * from './projection.ts';