deepclause-sdk 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +86 -3
  2. package/dist/agent.d.ts +17 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +148 -42
  5. package/dist/agent.js.map +1 -1
  6. package/dist/cli/config.d.ts +12 -12
  7. package/dist/cli/repl.d.ts +13 -1
  8. package/dist/cli/repl.d.ts.map +1 -1
  9. package/dist/cli/repl.js +177 -5
  10. package/dist/cli/repl.js.map +1 -1
  11. package/dist/cli/run.d.ts.map +1 -1
  12. package/dist/cli/run.js +4 -0
  13. package/dist/cli/run.js.map +1 -1
  14. package/dist/forms/index.d.ts +3 -0
  15. package/dist/forms/index.d.ts.map +1 -0
  16. package/dist/forms/index.js +3 -0
  17. package/dist/forms/index.js.map +1 -0
  18. package/dist/forms/registry.d.ts +119 -0
  19. package/dist/forms/registry.d.ts.map +1 -0
  20. package/dist/forms/registry.js +256 -0
  21. package/dist/forms/registry.js.map +1 -0
  22. package/dist/forms/spec.d.ts +193 -0
  23. package/dist/forms/spec.d.ts.map +1 -0
  24. package/dist/forms/spec.js +295 -0
  25. package/dist/forms/spec.js.map +1 -0
  26. package/dist/index.d.ts +7 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +7 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/prolog/loader.d.ts +18 -0
  31. package/dist/prolog/loader.d.ts.map +1 -1
  32. package/dist/prolog/loader.js +26 -1
  33. package/dist/prolog/loader.js.map +1 -1
  34. package/dist/prolog-src/deepclause_mi.pl +786 -40
  35. package/dist/repl/agent.d.ts +11 -0
  36. package/dist/repl/agent.d.ts.map +1 -1
  37. package/dist/repl/agent.js +23 -9
  38. package/dist/repl/agent.js.map +1 -1
  39. package/dist/repl/form-widget.d.ts +56 -0
  40. package/dist/repl/form-widget.d.ts.map +1 -0
  41. package/dist/repl/form-widget.js +289 -0
  42. package/dist/repl/form-widget.js.map +1 -0
  43. package/dist/repl/harness-tools.d.ts +4 -1
  44. package/dist/repl/harness-tools.d.ts.map +1 -1
  45. package/dist/repl/harness-tools.js +15 -58
  46. package/dist/repl/harness-tools.js.map +1 -1
  47. package/dist/repl/history.d.ts +24 -1
  48. package/dist/repl/history.d.ts.map +1 -1
  49. package/dist/repl/history.js +76 -8
  50. package/dist/repl/history.js.map +1 -1
  51. package/dist/repl/index.d.ts +20 -2
  52. package/dist/repl/index.d.ts.map +1 -1
  53. package/dist/repl/index.js +9 -0
  54. package/dist/repl/index.js.map +1 -1
  55. package/dist/repl/process-backend.d.ts +2 -2
  56. package/dist/repl/process-backend.d.ts.map +1 -1
  57. package/dist/repl/process-executor.d.ts +11 -2
  58. package/dist/repl/process-executor.d.ts.map +1 -1
  59. package/dist/repl/process-executor.js +67 -18
  60. package/dist/repl/process-executor.js.map +1 -1
  61. package/dist/repl/process-worker.d.ts +35 -4
  62. package/dist/repl/process-worker.d.ts.map +1 -1
  63. package/dist/repl/process-worker.js +78 -38
  64. package/dist/repl/process-worker.js.map +1 -1
  65. package/dist/repl/processes.d.ts +4 -0
  66. package/dist/repl/processes.d.ts.map +1 -1
  67. package/dist/repl/processes.js +20 -0
  68. package/dist/repl/processes.js.map +1 -1
  69. package/dist/repl/render.d.ts +17 -0
  70. package/dist/repl/render.d.ts.map +1 -1
  71. package/dist/repl/render.js +60 -0
  72. package/dist/repl/render.js.map +1 -1
  73. package/dist/repl/session.d.ts +143 -7
  74. package/dist/repl/session.d.ts.map +1 -1
  75. package/dist/repl/session.js +317 -20
  76. package/dist/repl/session.js.map +1 -1
  77. package/dist/repl/tui/form-view.d.ts +17 -0
  78. package/dist/repl/tui/form-view.d.ts.map +1 -0
  79. package/dist/repl/tui/form-view.js +25 -0
  80. package/dist/repl/tui/form-view.js.map +1 -0
  81. package/dist/repl/tui/renderer.d.ts +4 -0
  82. package/dist/repl/tui/renderer.d.ts.map +1 -1
  83. package/dist/repl/tui/renderer.js +12 -0
  84. package/dist/repl/tui/renderer.js.map +1 -1
  85. package/dist/repl/worker-host.d.ts +16 -2
  86. package/dist/repl/worker-host.d.ts.map +1 -1
  87. package/dist/repl/worker-host.js +38 -6
  88. package/dist/repl/worker-host.js.map +1 -1
  89. package/dist/repl/worker-protocol.d.ts +14 -0
  90. package/dist/repl/worker-protocol.d.ts.map +1 -1
  91. package/dist/repl/worker-protocol.js +8 -1
  92. package/dist/repl/worker-protocol.js.map +1 -1
  93. package/dist/runner.d.ts +69 -1
  94. package/dist/runner.d.ts.map +1 -1
  95. package/dist/runner.js +381 -44
  96. package/dist/runner.js.map +1 -1
  97. package/dist/sdk.d.ts.map +1 -1
  98. package/dist/sdk.js +12 -0
  99. package/dist/sdk.js.map +1 -1
  100. package/dist/system/assets/docs/AGENT_PROMPT.md +452 -18
  101. package/dist/system/assets/docs/CHAT_PROMPT.md +28 -0
  102. package/dist/system/assets/docs/DML_AUTHORING.md +8 -4
  103. package/dist/system/assets/docs/DML_FORMS.md +740 -0
  104. package/dist/system/assets/docs/DML_REFERENCE.md +239 -4
  105. package/dist/system/assets/docs/HARNESS_EXAMPLES.md +94 -0
  106. package/dist/system/assets/index.d.ts +2 -2
  107. package/dist/system/assets/index.d.ts.map +1 -1
  108. package/dist/system/assets/index.js +4 -0
  109. package/dist/system/assets/index.js.map +1 -1
  110. package/dist/system/environment.d.ts +6 -0
  111. package/dist/system/environment.d.ts.map +1 -1
  112. package/dist/system/environment.js +5 -4
  113. package/dist/system/environment.js.map +1 -1
  114. package/dist/types.d.ts +48 -1
  115. package/dist/types.d.ts.map +1 -1
  116. package/package.json +4 -2
  117. package/src/prolog-src/deepclause_mi.pl +786 -40
@@ -13,6 +13,7 @@ import { loadProlog, mountWorkspace, takePrologMessages, } from '../prolog/loade
13
13
  import { describeShellExecutionBackend, } from '../system/runtime/shell-manager.js';
14
14
  import { getWorkspaceDocAssetPath } from '../system/assets/index.js';
15
15
  import { DMLRunner, toJsValue } from '../runner.js';
16
+ import { resolveReplJudgeSelection } from './judge.js';
16
17
  import { DmlProcessExecutor } from './process-executor.js';
17
18
  import { pidKey } from './processes.js';
18
19
  import { compactMemory, estimateMemoryTokens, shouldCompact, toCompactionEvent, } from './compaction.js';
@@ -23,6 +24,7 @@ import { buildHarnessHeader, } from './export.js';
23
24
  import { HistoryStore, harnessHash, moduleVersion, } from './history.js';
24
25
  import { buildDiagramPrompt, extractMermaid, parseMermaidFlowchart, rasterizeSvg, renderFlowchartSvg, renderMermaidAscii, resolveMedia, validateMermaid, } from '../visualize/index.js';
25
26
  import { generateLlmReply } from '../prolog/bridge.js';
27
+ import { FormRegistry } from '../forms/registry.js';
26
28
  import { formatModelCatalogEntry } from './model-resolver.js';
27
29
  /**
28
30
  * Context note added when a saved session is reloaded. The opening words are
@@ -52,6 +54,16 @@ function historyEventFromRecord(record) {
52
54
  case 'answer':
53
55
  case 'error':
54
56
  return { type, frameKind, content };
57
+ case 'html':
58
+ return {
59
+ type,
60
+ frameKind,
61
+ html: {
62
+ path: typeof record.html?.path === 'string'
63
+ ? record.html.path
64
+ : '',
65
+ },
66
+ };
55
67
  case 'log':
56
68
  return { type, frameKind, content, level: record.level };
57
69
  case 'tool_call':
@@ -62,6 +74,10 @@ function historyEventFromRecord(record) {
62
74
  toolState: record.toolState,
63
75
  toolSummary: typeof record.toolSummary === 'string' ? record.toolSummary : undefined,
64
76
  toolArgs: record.toolArgs,
77
+ toolResult: record.toolResult,
78
+ toolError: typeof record.toolError === 'string' ? record.toolError : undefined,
79
+ toolDurationMs: typeof record.toolDurationMs === 'number' ? record.toolDurationMs : undefined,
80
+ toolExitCode: typeof record.toolExitCode === 'number' ? record.toolExitCode : undefined,
65
81
  };
66
82
  case 'solution':
67
83
  return {
@@ -123,8 +139,11 @@ export class DMLSession {
123
139
  model;
124
140
  provider;
125
141
  tools;
142
+ authoringTools;
126
143
  toolPolicy;
127
144
  inputHandler;
145
+ /** Host-side interaction for an opened form (docs/DML_FORMS.md). */
146
+ formHandler;
128
147
  maxDepth;
129
148
  maxModelCalls;
130
149
  hostToolsCatalog;
@@ -155,6 +174,7 @@ export class DMLSession {
155
174
  modelCallBudget;
156
175
  dmlReference;
157
176
  dmlAuthoring;
177
+ dmlForms;
158
178
  harnessExamples;
159
179
  harnessPatterns;
160
180
  prologSnippets;
@@ -175,6 +195,8 @@ export class DMLSession {
175
195
  /** Session-scoped subagents (spec §2). */
176
196
  processBackend;
177
197
  processBackendPromise;
198
+ /** Form host state (docs/DML_FORMS.md); active only when the harness opts in. */
199
+ forms = new FormRegistry();
178
200
  /**
179
201
  * The harness snapshot new processes should run. On the main session this is
180
202
  * unset (children snapshot the live module); a worker host sets it to the
@@ -187,6 +209,7 @@ export class DMLSession {
187
209
  static PROCESS_LOG_LIMIT = 500;
188
210
  /** Service declarations replayed from the session record (resume only). */
189
211
  recordedServices = [];
212
+ pendingSuspensionValue;
190
213
  /** Monotonic per-session turn counter used by history records. */
191
214
  turnCounter = 0;
192
215
  historyWarned = false;
@@ -198,11 +221,13 @@ export class DMLSession {
198
221
  this.model = options.runnerOptions.model;
199
222
  this.provider = String(options.runnerOptions.provider);
200
223
  this.tools = options.tools ?? new Map();
224
+ this.authoringTools = options.authoringTools ?? new Map();
201
225
  this.toolPolicy = options.toolPolicy ?? null;
202
226
  this.inputHandler = options.onInput
203
227
  ?? (options.askUser ? (prompt, choices) => options.askUser(prompt, choices).then((r) => ('cancelled' in r ? undefined : r.answer)) : undefined)
204
228
  ?? options.onInputRequired
205
229
  ?? undefined;
230
+ this.formHandler = options.onForm;
206
231
  this.maxDepth = options.maxDepth ?? 3;
207
232
  this.maxModelCalls = options.maxModelCalls ?? 100;
208
233
  this.modelCallBudget = this.maxModelCalls;
@@ -222,9 +247,26 @@ export class DMLSession {
222
247
  this.modelResolver = options.runnerOptions.modelResolver;
223
248
  this.temperature = options.runnerOptions.temperature;
224
249
  this.maxTokens = options.runnerOptions.maxTokens;
250
+ // Direct DMLSession users (notably the browser child workers) do not go
251
+ // through createHarnessRepl's judge wiring. Without a default selection,
252
+ // judge/2 fails with "Unknown judgment backend: llm".
253
+ const judgeSelection = options.runnerOptions.judgeSelection
254
+ ?? resolveReplJudgeSelection({
255
+ llmBackend: options.runnerOptions.llmBackend,
256
+ model: options.runnerOptions.model,
257
+ provider: String(options.runnerOptions.provider),
258
+ temperature: options.runnerOptions.temperature,
259
+ maxTokens: options.runnerOptions.maxTokens,
260
+ baseUrl: options.runnerOptions.baseUrl,
261
+ providerOptions: options.runnerOptions.providerOptions,
262
+ defaultJudge: options.defaultJudge,
263
+ jev: options.jev,
264
+ }).selection;
225
265
  this.runner = new DMLRunner(swipl, {
226
266
  ...options.runnerOptions,
267
+ judgeSelection,
227
268
  environment: this.executionEnvironment,
269
+ capabilityTool: options.capabilityTool,
228
270
  compactMessages: options.compaction
229
271
  ? (messages, lastInputTokens, contextWindow) => this.compactLoopMessages(messages, lastInputTokens, contextWindow)
230
272
  : undefined,
@@ -250,9 +292,11 @@ export class DMLSession {
250
292
  const store = new HistoryStore(options.workspacePath ?? process.cwd(), session.sessionId, options.history, session.harnessName);
251
293
  snapshot = await store.readSessionHarness();
252
294
  session.recordedServices = await store.readServiceRecords();
253
- if (!snapshot) {
254
- throw new Error(`session not found or has no saved harness: ${session.sessionId}`);
255
- }
295
+ session.pendingSuspensionValue = (await store.readSuspendRecord()) ?? undefined;
296
+ // A session can have a transcript but no harness snapshot (older builds,
297
+ // or a turn interrupted before its first commit). Resume it from the
298
+ // current harness instead of refusing to open it; the transcript reader
299
+ // still restores every turn and event it has.
256
300
  }
257
301
  const sessionParams = {
258
302
  workspace_path: options.workspacePath ?? './',
@@ -292,6 +336,27 @@ export class DMLSession {
292
336
  session.warnHistory(new Error(`could not load harness '${session.harnessName}': ${loaded.error}`));
293
337
  }
294
338
  }
339
+ if (options.resume)
340
+ session.noteSessionResumed();
341
+ }
342
+ // Creating a session (or loading a harness) can move SWI's cwd away from the
343
+ // workspace, so reassert it before any goal runs — with the **mounted** path.
344
+ // `mountWorkspace` puts every workspace at `/workspace` and points both
345
+ // Emscripten's cwd and SWI's there; the host path is not in the WASM filesystem,
346
+ // so asserting *that* throws (harmlessly in the main thread, but it means the
347
+ // reassert never happened where it was needed).
348
+ //
349
+ // `working_directory/2` is process-global, so this is only meaningful while a
350
+ // process has one mount — true for every session today, since the mount itself
351
+ // is process-global. Query with `.once()`: `prolog.call()` returns an
352
+ // unexecuted query and wedges the next one.
353
+ if (options.workspacePath) {
354
+ try {
355
+ swipl.prolog.query("working_directory(_, '/workspace').").once();
356
+ }
357
+ catch {
358
+ /* non-fatal: absolute paths still resolve */
359
+ }
295
360
  }
296
361
  return session;
297
362
  }
@@ -354,7 +419,12 @@ export class DMLSession {
354
419
  };
355
420
  if (mode === 'worker') {
356
421
  const { RemoteProcessHost } = await import('./worker-host.js');
357
- const host = new RemoteProcessHost(this, { onEvent });
422
+ const host = new RemoteProcessHost(this, {
423
+ onEvent,
424
+ workerFactory: this.openOptions.processWorkerFactory,
425
+ beforeStart: this.openOptions.beforeProcessStart,
426
+ beforeSpawn: this.openOptions.beforeProcessSpawn,
427
+ });
358
428
  await host.start();
359
429
  this.processBackend = host;
360
430
  return host;
@@ -398,6 +468,47 @@ export class DMLSession {
398
468
  recordedProcesses() {
399
469
  return this.recordedServices;
400
470
  }
471
+ /**
472
+ * A turn stopped by `suspend/1`: where it stopped and the goal that continues
473
+ * it. Set on resume, so a host can run the continuation once the harness and
474
+ * memory are back.
475
+ */
476
+ pendingSuspension() {
477
+ return this.pendingSuspensionValue;
478
+ }
479
+ /** Forget the pending suspension (after resuming it, or deliberately dropping it). */
480
+ async clearSuspension() {
481
+ this.pendingSuspensionValue = undefined;
482
+ await this.history?.clearSuspendRecord();
483
+ }
484
+ /**
485
+ * Persist a suspension: the continuation, the memory it stopped with, and the
486
+ * module version it was running. The harness snapshot is written too, so the
487
+ * resumed session starts from exactly this module + memory.
488
+ */
489
+ async handleSuspend(payload) {
490
+ if (!this.history)
491
+ return;
492
+ try {
493
+ if (payload.memory) {
494
+ // The turn's threaded memory is the authoritative one at the stop; make it
495
+ // the session's, so `harness.json` and the resumed state agree.
496
+ this.prologQuery(`deepclause_mi:repl_set_memory('${this.sessionId}', harness, ${payload.memory})`);
497
+ }
498
+ await this.history.writeSuspendRecord({
499
+ continuation: payload.continuation,
500
+ memory: payload.memory,
501
+ turn: this.turnCounter,
502
+ moduleVersion: this.history.currentVersion ? String(this.history.currentVersion) : undefined,
503
+ moduleHash: this.history.currentHash,
504
+ });
505
+ await this.persistSessionHarness(this.history.currentVersion);
506
+ this.pendingSuspensionValue = { continuation: payload.continuation, memory: payload.memory };
507
+ }
508
+ catch (error) {
509
+ this.warnHistory(error);
510
+ }
511
+ }
401
512
  /**
402
513
  * Recent subagent output for the log view, oldest first. Answers and exits are
403
514
  * included; per-tick state/activity updates are not (the summary shows those).
@@ -410,7 +521,7 @@ export class DMLSession {
410
521
  * Events from the resumed session, to render it on load. Empty unless this
411
522
  * session was opened with `resume` and history is enabled.
412
523
  */
413
- async resumedTranscript(limit = 400) {
524
+ async resumedTranscript(limit = Number.POSITIVE_INFINITY) {
414
525
  if (!this.resume || !this.history)
415
526
  return [];
416
527
  const records = await this.history.readEvents(this.sessionId, limit);
@@ -481,11 +592,40 @@ export class DMLSession {
481
592
  sendProcess(to, messageTerm) {
482
593
  this.processBackend?.send(to, messageTerm);
483
594
  }
484
- /** Request/reply against a service process (library(service) protocol). */
485
- async callProcess(to, messageTerm, timeoutMs = 300_000) {
486
- return (await this.ensureProcessBackend()).callTerm(to, messageTerm, timeoutMs);
595
+ /**
596
+ * Request/reply against a service process (library(service) protocol).
597
+ *
598
+ * The message is normalized first: prose ("hello from the REPL") is not a
599
+ * Prolog term, and interpolating it into `request(From, Ref, …)` used to leave
600
+ * the caller waiting out the timeout while the service failed to parse the goal.
601
+ */
602
+ async callProcess(to, messageTerm, timeoutMs = 30_000, signal) {
603
+ return (await this.ensureProcessBackend()).callTerm(to, this.normalizeMessage(messageTerm), timeoutMs, signal);
604
+ }
605
+ /** Quote a `call` message that does not parse as a term, so it is a payload. */
606
+ normalizeMessage(text) {
607
+ if (!text.trim())
608
+ return 'true';
609
+ const result = this.prologQuery(`deepclause_mi:repl_normalize_message(${quoteString(text)}, Term)`);
610
+ return String(plainValue(result?.Term) ?? text);
611
+ }
612
+ /** The synthetic pid shared by foreground/execute work in this session. */
613
+ async processHostPid() {
614
+ const backend = await this.ensureProcessBackend();
615
+ const hostPid = backend.hostPid?.();
616
+ if (!hostPid)
617
+ throw new Error('this process backend has no host pid');
618
+ return hostPid;
619
+ }
620
+ /** Handle a process primitive on behalf of a remote child session. */
621
+ async handleProcessRequest(pid, request, signal) {
622
+ const backend = await this.ensureProcessBackend();
623
+ const candidate = backend;
624
+ if (typeof candidate.handleRequest !== 'function') {
625
+ throw new Error('this process backend cannot service remote child requests');
626
+ }
627
+ return candidate.handleRequest(pid, request, signal);
487
628
  }
488
- /** Resolve a process primitive for this (non-process) foreground run. */
489
629
  async handleForeground(request, signal) {
490
630
  return (await this.ensureProcessBackend()).foregroundHandle(request, signal);
491
631
  }
@@ -570,7 +710,12 @@ export class DMLSession {
570
710
  * (a swarm item reports `failed`, say), whereas throwing would kill the process
571
711
  * before it can report and leave a waiting parent hanging.
572
712
  */
573
- async createProcessSession() {
713
+ async createProcessSession(pid) {
714
+ if (this.openOptions.createProcessSession) {
715
+ if (!pid)
716
+ throw new Error('createProcessSession requires a pid');
717
+ return this.openOptions.createProcessSession(pid);
718
+ }
574
719
  const text = this.processSnapshotFor().text;
575
720
  const child = await DMLSession.open({
576
721
  ...this.openOptions,
@@ -860,6 +1005,25 @@ export class DMLSession {
860
1005
  return undefined;
861
1006
  }
862
1007
  }
1008
+ /** Write the user turn immediately, so an interrupted turn keeps its request. */
1009
+ async recordHistoryTurnStart(record) {
1010
+ if (!this.history)
1011
+ return;
1012
+ try {
1013
+ this.assertHistoryFacts(await this.history.recordTurn({
1014
+ ...record,
1015
+ outcome: 'running',
1016
+ durationMs: 0,
1017
+ modelCalls: 0,
1018
+ }));
1019
+ // Snapshot before the turn as well: an interrupted turn then still has a
1020
+ // resumable harness and transcript, not just the previous turn's files.
1021
+ await this.persistSessionHarness(this.history.currentVersion);
1022
+ }
1023
+ catch (error) {
1024
+ this.warnHistory(error);
1025
+ }
1026
+ }
863
1027
  async finishHistoryTurn(record) {
864
1028
  if (!this.history)
865
1029
  return;
@@ -942,6 +1106,7 @@ export class DMLSession {
942
1106
  this.turnCounter += 1;
943
1107
  const historyTurn = this.turnCounter;
944
1108
  const historyStart = Date.now();
1109
+ await this.recordHistoryTurnStart({ turn: historyTurn, mode: 'goal', request: goal });
945
1110
  const frame = this.runner.registerDefaultFrame(frameKind);
946
1111
  if (!frame) {
947
1112
  yield { type: 'error', content: 'Failed to create query frame' };
@@ -976,6 +1141,7 @@ export class DMLSession {
976
1141
  durationMs: Date.now() - historyStart,
977
1142
  modelCalls: this.modelCallsThisTurn,
978
1143
  });
1144
+ await this.persistSessionHarness(this.history?.currentVersion ?? 0);
979
1145
  }
980
1146
  /** Load the bundled DML reference once, for inclusion in the meta prompt. */
981
1147
  async loadDmlReference() {
@@ -1005,6 +1171,17 @@ export class DMLSession {
1005
1171
  }
1006
1172
  /** Load a bundled doc asset once (cached by field). */
1007
1173
  async loadDocAsset(name) {
1174
+ if (name === 'dml-forms') {
1175
+ if (this.dmlForms !== undefined)
1176
+ return this.dmlForms;
1177
+ try {
1178
+ this.dmlForms = await fs.readFile(getWorkspaceDocAssetPath('dml-forms'), 'utf8');
1179
+ }
1180
+ catch {
1181
+ this.dmlForms = '';
1182
+ }
1183
+ return this.dmlForms;
1184
+ }
1008
1185
  const cached = name === 'harness-examples'
1009
1186
  ? this.harnessExamples
1010
1187
  : name === 'harness-patterns'
@@ -1286,6 +1463,7 @@ export class DMLSession {
1286
1463
  this.turnCounter += 1;
1287
1464
  const turn = this.turnCounter;
1288
1465
  const historyStart = Date.now();
1466
+ await this.recordHistoryTurnStart({ turn, mode: 'prompt', request });
1289
1467
  this.lastTurnCommitted = false;
1290
1468
  this.lastCommitVersion = undefined;
1291
1469
  // No automatic transaction: `define`/`assert`/… are refused until the agent
@@ -1374,8 +1552,24 @@ export class DMLSession {
1374
1552
  async getHarnessAgent() {
1375
1553
  if (this.harnessAgent)
1376
1554
  return this.harnessAgent;
1555
+ const authoringTools = createHarnessTools(this);
1556
+ const allowed = this.openOptions.authoringToolNames
1557
+ ? new Set(this.openOptions.authoringToolNames)
1558
+ : null;
1559
+ if (allowed) {
1560
+ for (const name of [...authoringTools.keys()]) {
1561
+ if (!allowed.has(name))
1562
+ authoringTools.delete(name);
1563
+ }
1564
+ }
1565
+ for (const [name, tool] of this.authoringTools) {
1566
+ if (allowed && !allowed.has(name))
1567
+ continue;
1568
+ if (!authoringTools.has(name))
1569
+ authoringTools.set(name, tool);
1570
+ }
1377
1571
  this.harnessAgent = new HarnessAgent({
1378
- tools: createHarnessTools(this),
1572
+ tools: authoringTools,
1379
1573
  llmBackend: this.llmBackend,
1380
1574
  model: this.model,
1381
1575
  temperature: this.temperature,
@@ -1389,9 +1583,11 @@ export class DMLSession {
1389
1583
  : undefined,
1390
1584
  workspacePath: this.workspacePath,
1391
1585
  environment: this.executionEnvironment,
1586
+ capabilityTool: this.openOptions.capabilityTool,
1392
1587
  currentModel: `${this.provider}:${this.model}`,
1393
1588
  modelCatalog: this.modelResolver?.catalog(),
1394
1589
  knownMessages: this.agentMessages,
1590
+ agentSystemPrompt: this.openOptions.agentSystemPrompt,
1395
1591
  });
1396
1592
  return this.harnessAgent;
1397
1593
  }
@@ -1520,13 +1716,14 @@ export class DMLSession {
1520
1716
  return undefined;
1521
1717
  }
1522
1718
  async beginTurn() {
1523
- // A second begin_turn would re-snapshot the *mid-transaction* state, so
1719
+ // A second begin_turn must not re-snapshot the *mid-transaction* state:
1524
1720
  // `rollback` would stop meaning "the last committed harness" and the
1525
- // recorded diff would lose the edits made before it.
1721
+ // recorded diff would lose edits made before it. Treat it as an idempotent
1722
+ // reminder instead of an error so agents do not get stuck retrying it.
1526
1723
  if (this.transactionBefore !== null) {
1527
1724
  return {
1528
- ok: false,
1529
- error: 'a transaction is already open; call end_turn to commit it, or rollback to discard it',
1725
+ ok: true,
1726
+ message: 'transaction is already open; continue editing, then call end_turn to commit or rollback to discard',
1530
1727
  };
1531
1728
  }
1532
1729
  this.prologQuery(`deepclause_mi:repl_snapshot_save('${this.sessionId}')`);
@@ -1541,8 +1738,8 @@ export class DMLSession {
1541
1738
  }
1542
1739
  const before = this.transactionBefore;
1543
1740
  const after = this.predicateList();
1544
- const changed = this.simplePredicateDiff(before, after);
1545
1741
  const touched = this.editTouched();
1742
+ const changed = this.simplePredicateDiff(before, after, touched);
1546
1743
  this.transactionBefore = null;
1547
1744
  const committed = this.transactionDirty;
1548
1745
  let version;
@@ -1567,6 +1764,61 @@ export class DMLSession {
1567
1764
  warnings.push(...this.probeToolContracts());
1568
1765
  warnings.push(...this.probeDocContracts());
1569
1766
  }
1767
+ // `committed` means a version was recorded. It used to be hard-coded `true`,
1768
+ // so a turn whose every edit was refused reported `{committed: true,
1769
+ // changed: []}` — which reads as "committed, nothing changed" and hides that
1770
+ // the edit never landed.
1771
+ return { committed, changed, warnings };
1772
+ }
1773
+ /**
1774
+ * The living module serialized as loadable DML (the editable harness source).
1775
+ * `header: none` keeps it free of the export banner, so it round-trips through
1776
+ * `replaceHarness` without accumulating comments.
1777
+ */
1778
+ async harnessSource() {
1779
+ const result = this.prologQuery(`deepclause_mi:export_harness('${this.sessionId}', none, Text)`);
1780
+ return String(plainValue(result?.Text) ?? '');
1781
+ }
1782
+ /**
1783
+ * Replace the whole living module with `source` and commit it as a new
1784
+ * version.
1785
+ *
1786
+ * `loadCode` only adds/replaces clauses, so a predicate deleted from the text
1787
+ * would survive. This abolishes the user predicates (and their directives,
1788
+ * tools and sources) and reloads the text, preserving conversation memory, so
1789
+ * the editor's text is the source of truth. The result is an ordinary commit:
1790
+ * a history version, the persisted harness, and the same contract warnings.
1791
+ */
1792
+ async replaceHarness(source) {
1793
+ if (this.disposed)
1794
+ return { committed: false, error: 'Session has been disposed' };
1795
+ if (this.isFrozen())
1796
+ return { committed: false, error: 'harness is frozen; editing is disabled' };
1797
+ // `repl_rollback` abolishes the predicates *before* loading, so a syntax error
1798
+ // would otherwise leave the session empty. Keep the previous text and put it
1799
+ // back if the new source does not load.
1800
+ const previous = await this.harnessSource();
1801
+ const before = this.predicateList();
1802
+ const result = this.prologQuery(`deepclause_mi:repl_rollback('${this.sessionId}', ${quoteString(source)}, Result)`);
1803
+ const outcome = plainValue(result?.Result);
1804
+ if (outcome !== 'ok') {
1805
+ this.prologQuery(`deepclause_mi:repl_rollback('${this.sessionId}', ${quoteString(previous)}, _)`);
1806
+ return { committed: false, error: String(outcome ?? 'replace failed') };
1807
+ }
1808
+ const after = this.predicateList();
1809
+ const changed = this.simplePredicateDiff(before, after);
1810
+ let version;
1811
+ if (this.history) {
1812
+ version = await this.recordHistoryChange('commit', this.turnCounter, before, { touched: after });
1813
+ }
1814
+ this.lastTurnCommitted = true;
1815
+ this.lastCommitVersion = version;
1816
+ await this.persistSessionHarness(this.history?.currentVersion ?? 0);
1817
+ const warnings = [
1818
+ ...(await this.probeNondeterminism(after)),
1819
+ ...this.probeToolContracts(),
1820
+ ...this.probeDocContracts(),
1821
+ ];
1570
1822
  return { committed: true, changed, warnings };
1571
1823
  }
1572
1824
  /**
@@ -1658,13 +1910,27 @@ export class DMLSession {
1658
1910
  this.lastCommitVersion = undefined;
1659
1911
  this.transactionDirty = false;
1660
1912
  }
1661
- simplePredicateDiff(before, after) {
1913
+ /**
1914
+ * `+f/1` added, `-f/1` removed, `~f/1` rewritten in place.
1915
+ *
1916
+ * A set diff alone reports `[]` when a predicate is replaced by a new definition
1917
+ * with the same name and arity — the most common edit there is. An agent that
1918
+ * reads `changed` as "did my edit land?" was then told nothing had happened
1919
+ * while the clause block had in fact been swapped. `touched` is what makes the
1920
+ * rewrite visible: the edit tools replace by abolish-and-reassert, so anything
1921
+ * they touched and that still exists was rewritten.
1922
+ */
1923
+ simplePredicateDiff(before, after, touched = []) {
1662
1924
  const beforeSet = new Set(before);
1663
1925
  const afterSet = new Set(after);
1926
+ const touchedSet = new Set(touched);
1664
1927
  const changed = [];
1665
- for (const predicate of after)
1928
+ for (const predicate of after) {
1666
1929
  if (!beforeSet.has(predicate))
1667
1930
  changed.push(`+${predicate}`);
1931
+ else if (touchedSet.has(predicate))
1932
+ changed.push(`~${predicate}`);
1933
+ }
1668
1934
  for (const predicate of before)
1669
1935
  if (!afterSet.has(predicate))
1670
1936
  changed.push(`-${predicate}`);
@@ -1746,13 +2012,16 @@ export class DMLSession {
1746
2012
  return this.loadDocAsset('harness-patterns');
1747
2013
  case 'snippets':
1748
2014
  return this.loadDocAsset('prolog-snippets');
2015
+ case 'forms':
2016
+ case 'dml-forms':
2017
+ return this.loadDocAsset('dml-forms');
1749
2018
  case 'tools':
1750
2019
  return this.hostToolsCatalog ?? '(no host capability tools registered)';
1751
2020
  default:
1752
- return `unknown reference topic '${topic}'. Topics: language, authoring, examples, patterns, snippets, tools.`;
2021
+ return `unknown reference topic '${topic}'. Topics: language, authoring, examples, patterns, snippets, forms, tools.`;
1753
2022
  }
1754
2023
  }
1755
- async execute(goal, limit, signal, onEvent, bindingMode = 'full') {
2024
+ async execute(goal, limit, signal, onEvent, bindingMode = 'full', _timeoutMs) {
1756
2025
  return this.executeGoal(goal, limit ?? 5, onEvent, signal, bindingMode);
1757
2026
  }
1758
2027
  /**
@@ -1803,6 +2072,10 @@ export class DMLSession {
1803
2072
  onInputRequired: inputHandler
1804
2073
  ? (prompt) => this.requireInput(prompt)
1805
2074
  : async () => undefined,
2075
+ onForm: this.formHandler,
2076
+ // A turn that called suspend/1 stops here; the record is what --session
2077
+ // resumes from (docs/DML_REPL_SUSPEND_RESUME.md).
2078
+ onSuspend: (payload) => this.handleSuspend(payload),
1806
2079
  signal: options.signal,
1807
2080
  onModelCall: () => this.reserveModelCall(),
1808
2081
  // A run that is not itself a process resolves primitives through the
@@ -1810,9 +2083,33 @@ export class DMLSession {
1810
2083
  onProcessRequest: options.process
1811
2084
  ? (request, signal) => options.process.handle(request, signal)
1812
2085
  : (request, signal) => this.handleForeground(request, signal),
2086
+ forms: this.forms,
1813
2087
  };
1814
2088
  }
2089
+ /** The session's form host (docs/DML_FORMS.md). */
2090
+ formRegistry() {
2091
+ return this.forms;
2092
+ }
2093
+ /** Install the host-side interaction for opened forms. */
2094
+ setFormHandler(handler) {
2095
+ this.formHandler = handler;
2096
+ }
2097
+ /**
2098
+ * Send a form action from the UI (the REPL's half of the form protocol).
2099
+ * Resolves an `await_form`/`wait_for_form_change` blocked in the session.
2100
+ */
2101
+ submitForm(formId, change) {
2102
+ const record = this.forms.get(formId);
2103
+ if (!record || record.state !== 'open')
2104
+ return false;
2105
+ this.forms.submit(formId, change.action, change.values ?? {}, change.errors ?? []);
2106
+ return true;
2107
+ }
1815
2108
  // ---- TypeScript compaction (ported from pi) ----
2109
+ /** Read the live harness memory (for disposable execute workers). */
2110
+ harnessMemory() {
2111
+ return this.getMemory('harness');
2112
+ }
1816
2113
  /** Read a live memory slot as `MemoryMessage[]`. */
1817
2114
  getMemory(slot) {
1818
2115
  const result = this.prologQuery(`deepclause_mi:repl_get_memory('${this.sessionId}', ${slot}, Messages)`);