@jskit-ai/assistant-core 0.1.230 → 0.1.232

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.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.230",
11
+ "@jskit-ai/assistant-core": "0.1.232",
12
12
  "@jskit-ai/http-runtime": "0.1.242",
13
13
  "@jskit-ai/kernel": "0.1.244",
14
14
  "@mdi/js": "^7.4.47",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.230",
3
+ "version": "0.1.232",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -391,8 +391,8 @@ export function createClaudeConversationOwner({
391
391
  if (options && typeof options.then === "function") options = await options;
392
392
  assertPreparing();
393
393
  let startupCleanup;
394
- let retainedGoalClear;
395
394
  let startedExecutionId;
395
+ const processToken = {};
396
396
  try { entry.process = await createProcess({ ...options,
397
397
  workdir: entry.nativeWorkdir, sessionId: entry.id, resume: entry.sent,
398
398
  onStarted: async (executionId, stop) => {
@@ -417,12 +417,13 @@ export function createClaudeConversationOwner({
417
417
  // on its event reader: interrupt/exit also needs that reader to drain.
418
418
  pending.stop();
419
419
  }
420
- // A retained Pause disarms only the native goal hook before a new
421
- // authored input is admitted. This local command is not an AI turn.
422
- if (retainedGoalClear) {
423
- if (frame.type === "result" && frame.user_message_uuid === retainedGoalClear.id) {
424
- if (frame.local_command === "goal" && frame.is_error !== true && frame.subtype === "success") retainedGoalClear.resolve();
425
- else retainedGoalClear.reject(createError("Claude could not suspend its retained goal checker."));
420
+ // Local commands (retained goal clear, compaction) are not AI turns.
421
+ // Their frames stay out of the turn view until their own result.
422
+ const local = entry.localCommand;
423
+ if (local?.processToken === processToken) {
424
+ if (frame.type === "result" && frame.user_message_uuid === local.id) {
425
+ if (frame.local_command === local.name && frame.is_error !== true && frame.subtype === "success") local.resolve();
426
+ else local.reject(createError(local.failureMessage));
426
427
  }
427
428
  return;
428
429
  }
@@ -469,7 +470,7 @@ export function createClaudeConversationOwner({
469
470
  }
470
471
  } : {}),
471
472
  onFailure: async error => {
472
- retainedGoalClear?.reject(error);
473
+ if (entry.localCommand?.processToken === processToken) entry.localCommand.reject(error);
473
474
  if (store.releaseExecution) {
474
475
  entry.nativeTurn.invalidateInstructions();
475
476
  try { await entry.nativeTurn.stopProcess(error.message); }
@@ -505,30 +506,55 @@ export function createClaudeConversationOwner({
505
506
  throw error;
506
507
  }
507
508
  if (entry.startupCleanup?.executionId === entry.executionId) entry.startupCleanup = null;
509
+ entry.processToken = processToken;
508
510
  if (entry.pausedGoal || entry.blockedGoal) {
509
- const completion = Promise.withResolvers();
510
- completion.promise.catch(() => {});
511
- retainedGoalClear = { ...completion, id: randomUUID() };
512
- const timer = setTimeout(() => completion.reject(createError("Claude timed out suspending its retained goal checker.")), 30_000);
513
511
  try {
514
- await entry.process.client.send("/goal clear", { messageId: retainedGoalClear.id, sessionId: entry.id });
515
- await completion.promise;
512
+ const id = await runLocalCommand(entry, "/goal clear", { name: "goal", timeoutMs: 30_000,
513
+ failureMessage: "Claude could not suspend its retained goal checker.",
514
+ timeoutMessage: "Claude timed out suspending its retained goal checker." });
516
515
  const cleared = await readHistory(entry);
517
- if (cleared.goal || cleared.goalCommandId !== retainedGoalClear.id) {
516
+ if (cleared.goal || cleared.goalCommandId !== id) {
518
517
  throw createError("Claude has not saved the retained goal's native clear receipt.");
519
518
  }
520
519
  assertPreparing();
521
520
  } catch (error) {
522
521
  await entry.nativeTurn.stopProcess(error.message);
523
522
  throw Object.assign(error, { delivery: "not-sent" });
524
- } finally {
525
- clearTimeout(timer);
526
- retainedGoalClear = null;
527
523
  }
528
524
  }
529
525
  return entry.process;
530
526
  }
531
527
 
528
+ async function runLocalCommand(entry, command, { name, timeoutMs, failureMessage, timeoutMessage }) {
529
+ const completion = Promise.withResolvers();
530
+ completion.promise.catch(() => {});
531
+ const local = { ...completion, id: randomUUID(), name, failureMessage, processToken: entry.processToken };
532
+ entry.localCommand = local;
533
+ const timer = setTimeout(() => completion.reject(createError(timeoutMessage)), timeoutMs);
534
+ try {
535
+ await entry.process.client.send(command, { messageId: local.id, sessionId: entry.id });
536
+ await completion.promise;
537
+ return local.id;
538
+ } finally {
539
+ clearTimeout(timer);
540
+ if (entry.localCommand === local) entry.localCommand = null;
541
+ }
542
+ }
543
+
544
+ // Native /compact summarises Claude's own history in place, exactly like its
545
+ // automatic compaction. It is not an AI turn and adds no chat message.
546
+ async function compactContext(entry, input = {}) {
547
+ input.signal?.throwIfAborted();
548
+ if (entry.turn?.active || entry.nativeTurn.read().active) {
549
+ throw Object.assign(createError("Claude is still working. Wait for it to finish before freeing its context."), { statusCode: 409 });
550
+ }
551
+ await ensureProcess(entry, input);
552
+ input.signal?.throwIfAborted();
553
+ await runLocalCommand(entry, "/compact", { name: "compact", timeoutMs: 15 * 60_000,
554
+ failureMessage: "Claude could not summarise its context.",
555
+ timeoutMessage: "Claude timed out summarising its context." });
556
+ }
557
+
532
558
  async function send(entry, input = {}, { renewal = false, command, applicationCommand } = {}) {
533
559
  const message = command ? input.text : text(input.message || input.prompt);
534
560
  if (!command && !message) throw createError("Enter a message for Claude.");
@@ -1090,7 +1116,7 @@ export function createClaudeConversationOwner({
1090
1116
  if (!binding) return { value: delivered.value };
1091
1117
  }
1092
1118
 
1093
- return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, ensureReady, ensureProcess, readContextUsage, stopForTerminal, prepareTerminal, send, startTurn,
1119
+ return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, ensureReady, ensureProcess, readContextUsage, compactContext, stopForTerminal, prepareTerminal, send, startTurn,
1094
1120
  interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
1095
1121
  readGoal, updateGoal, renewal, run, steer, cancel });
1096
1122
  }
@@ -418,7 +418,9 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
418
418
  ...(budget ? { maximumHistoryBytes: async options => {
419
419
  await access(context, entry.id, request.operation, entry);
420
420
  signal.throwIfAborted();
421
- const bytes = await budget({ ...options, context, signal });
421
+ // A host may free native context first; show that as compacting.
422
+ const bytes = await budget({ ...options, context, signal,
423
+ setPhase: phase => setPhase(entry, phase === "compacting" ? "compacting" : "preparing") });
422
424
  signal.throwIfAborted();
423
425
  return bytes;
424
426
  } } : {}),
@@ -2242,6 +2242,53 @@ test("Claude context inspection refuses unavailable usage and honours cancellati
2242
2242
  assert.equal(f.messages.length, 0);
2243
2243
  });
2244
2244
 
2245
+ test("Claude context compaction runs native /compact as a local command without a chat turn", async t => {
2246
+ const f = await publishedClaudeFixture(t);
2247
+ let usage = { model: "claude-opus", totalTokens: 946000, maxTokens: 1000000, autoCompactThreshold: 967000 };
2248
+ f.behavior.initialize = native => {
2249
+ native.client.request = async request => request.subtype === "get_context_usage" ? usage : {};
2250
+ const send = native.client.send;
2251
+ native.client.send = async (message, input) => {
2252
+ if (message !== "/compact") return send(message, input);
2253
+ (f.behavior.nativeInputs ||= []).push(message);
2254
+ for (const frame of [{ type: "user", uuid: input.messageId }, { type: "system", subtype: "status", status: "compacting" },
2255
+ { type: "system", subtype: "compact_boundary", compact_metadata: { trigger: "manual" } },
2256
+ { type: "user", uuid: "summary", message: { content: "This session is being continued..." } }]) {
2257
+ await native.options.onEvent({ ...frame, session_id: native.options.sessionId });
2258
+ }
2259
+ usage = { ...usage, totalTokens: 40000 };
2260
+ await native.options.onEvent({ type: "result", subtype: "success", result: "", local_command: "compact",
2261
+ user_message_uuid: input.messageId, session_id: native.options.sessionId });
2262
+ };
2263
+ };
2264
+ await f.owner.compactContext(f.entry);
2265
+ assert.deepEqual(f.behavior.nativeInputs, ["/compact"]);
2266
+ assert.equal((await f.owner.readContextUsage(f.entry)).usedTokens, 40000);
2267
+ assert.equal(f.entry.turn, null);
2268
+ assert.equal(f.entry.localCommand, null);
2269
+ assert.equal(f.messages.length, 0);
2270
+ assert.equal(f.processes.length, 1);
2271
+ await f.send("after-compaction");
2272
+ assert.deepEqual(f.behavior.nativeInputs.slice(-1), ["Explain"]);
2273
+ assert.equal(f.entry.turn.active, true);
2274
+ });
2275
+
2276
+ test("Claude context compaction reports native failure and refuses an active turn", async t => {
2277
+ const f = await publishedClaudeFixture(t);
2278
+ f.behavior.initialize = native => {
2279
+ const send = native.client.send;
2280
+ native.client.send = async (message, input) => {
2281
+ if (message !== "/compact") return send(message, input);
2282
+ await native.options.onEvent({ type: "result", subtype: "error_during_execution", is_error: true,
2283
+ local_command: "compact", user_message_uuid: input.messageId, session_id: native.options.sessionId });
2284
+ };
2285
+ };
2286
+ await assert.rejects(f.owner.compactContext(f.entry), /could not summarise its context/);
2287
+ assert.equal(f.entry.localCommand, null);
2288
+ await f.send("working");
2289
+ await assert.rejects(f.owner.compactContext(f.entry), error => error.statusCode === 409 && /still working/.test(error.message));
2290
+ });
2291
+
2245
2292
 
2246
2293
  test("bound Main history budget transfers more than 30 complete messages through the original Claude sender", { timeout: 10_000 }, async t => {
2247
2294
  const f = await suppliedCommonFixture(t, { tools: false, representation: "native" });
@@ -2251,8 +2298,14 @@ test("bound Main history budget transfers more than 30 complete messages through
2251
2298
  f.binding.transcript.history = async () => history;
2252
2299
  await f.binding.state.write({ lastEngine: "codex", engines: { codex: { seen: {} } } });
2253
2300
  let reads = 0;
2254
- f.binding.maximumHistoryBytes = async ({ engineId, returning, context, signal, input }) => {
2301
+ const phases = [];
2302
+ const canonical = await f.runtime.open({ id: "lesson", context: f.toolContext });
2303
+ f.binding.maximumHistoryBytes = async ({ engineId, returning, context, signal, input, setPhase }) => {
2255
2304
  reads++;
2305
+ await setPhase("compacting");
2306
+ phases.push((await canonical.read()).phase);
2307
+ await setPhase("preparing");
2308
+ phases.push((await canonical.read()).phase);
2256
2309
  assert.equal(engineId, "claude");
2257
2310
  assert.equal(returning, false);
2258
2311
  assert.equal(context.actor.id, "owner");
@@ -2267,6 +2320,7 @@ test("bound Main history budget transfers more than 30 complete messages through
2267
2320
  };
2268
2321
  await sendCommon(f, "full-history");
2269
2322
  assert.equal(reads, 1);
2323
+ assert.deepEqual(phases, ["compacting", "preparing"], "a host freeing native context reports it as compacting");
2270
2324
  assert.ok(sent[0].length > 128_000);
2271
2325
  for (const message of history) assert.ok(sent[0].includes(JSON.stringify(message.text)), message.id);
2272
2326
  await f.frame({ type: "result", subtype: "success", result: "Received." });