@liwu1995/afu-context 0.1.6 → 0.1.11

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.
@@ -13,23 +13,84 @@ from pathlib import Path
13
13
  from afu_context import native_install as fs
14
14
  from afu_context.diagnostics import state_root
15
15
  from afu_context.management import report, validate_policy
16
+ from afu_context.host_command import host_command
17
+ from afu_context.platform_runtime import redirected
18
+
19
+
20
+ def node_binary():
21
+ """The Node.js running this launcher (npx), so Windows needs no PATH lookup."""
22
+ return os.environ.get("AFU_NODE") or shutil.which("node") or "node"
23
+
24
+
25
+ def dsh_command():
26
+ """argv that starts DSH, plus the path of its real entry script.
27
+
28
+ npm installs a POSIX symlink to lib/bin.js, but on Windows a dsh.cmd shim
29
+ that subprocess cannot run without a shell. Running node on the package's
30
+ own entry script works on both and needs no shell.
31
+ """
32
+ found = shutil.which("dsh")
33
+ if not found:
34
+ raise ValueError("dsh is not installed")
35
+ if not WINDOWS():
36
+ return [found], os.path.realpath(found)
37
+ command = host_command(found, "dsh", platform="nt")
38
+ return command, command[-1]
16
39
 
17
40
 
18
41
  def run(*args, stdin=None):
42
+ args = [str(a) for a in args]
43
+ if args and args[0] == "node":
44
+ args[0] = node_binary()
19
45
  result = subprocess.run(
20
- args, input=stdin, capture_output=True, text=True, timeout=30
46
+ args, input=stdin, capture_output=True, text=True, encoding="utf-8" if WINDOWS() else None, timeout=30
21
47
  )
22
48
  if result.returncode:
23
49
  raise ValueError("Host configuration could not be loaded")
24
50
  return result.stdout
25
51
 
26
52
 
53
+ def WINDOWS():
54
+ return os.name == "nt"
55
+
56
+
57
+ def is_link(path):
58
+ """A symlink, or on Windows a directory junction (both point elsewhere)."""
59
+ if WINDOWS():
60
+ return redirected(path)
61
+ if path.is_symlink():
62
+ return True
63
+ is_junction = getattr(path, "is_junction", None)
64
+ return bool(is_junction and is_junction())
65
+
66
+
67
+ def create_junction(target, link):
68
+ import _winapi
69
+ # COSEC: native junction API preserves literal paths without cmd expansion.
70
+ _winapi.CreateJunction(target, link)
71
+
72
+
73
+ def link_directory(link, target):
74
+ """Point link at target. Windows uses a junction: no admin rights needed."""
75
+ if WINDOWS():
76
+ create_junction(str(target), str(link))
77
+ return
78
+ link.symlink_to(target, target_is_directory=True)
79
+
80
+
81
+ def unlink_directory(link):
82
+ if WINDOWS() and not link.is_symlink():
83
+ os.rmdir(link) # Removes the junction itself, never the target files.
84
+ else:
85
+ link.unlink()
86
+
87
+
27
88
  def write_owned(file, content, marker):
28
89
  if file.is_symlink():
29
90
  raise ValueError("Refusing to replace an extension symlink")
30
- if file.exists() and file.read_text() == content:
91
+ if file.exists() and file.read_text(encoding="utf-8" if WINDOWS() else None) == content:
31
92
  return
32
- if file.exists() and not file.read_text().startswith(marker):
93
+ if file.exists() and not file.read_text(encoding="utf-8" if WINDOWS() else None).startswith(marker):
33
94
  raise ValueError("Existing extension belongs to another installation")
34
95
  fs.atomic(file, content)
35
96
 
@@ -53,8 +114,9 @@ def install_pi(root, adapter, settings, previous):
53
114
  raise ValueError("Pi extension changed since installation; user file preserved")
54
115
  marker = "// Managed by AFU " + root.as_uri() + "\n"
55
116
  # The explicit launcher supplies its own extension; avoid installing hooks twice.
56
- body = marker + "import register from " + json.dumps(str(adapter)) + ";\n"
57
- body += "export default function(pi) {\n if (process.env.AFU_EXPLICIT_EXTENSION === 'pi' || (process.env.AFU_CONTEXT_COMPACT_WORKER_COMMAND && process.argv.some(a => a.endsWith('/afu_host_integrations/pi/dist/index.js')))) return;\n"
117
+ # A file: URL imports on every OS (a bare "C:\\..." path is not a valid specifier).
118
+ body = marker + "import register from " + json.dumps(Path(adapter).resolve().as_uri()) + ";\n"
119
+ body += "export default function(pi) {\n if (process.env.AFU_EXPLICIT_EXTENSION === 'pi' || (process.env.AFU_CONTEXT_COMPACT_WORKER_COMMAND && process.argv.some(a => a.replace(/\\\\/g, '/').endsWith('/afu_host_integrations/pi/dist/index.js')))) return;\n"
58
120
  body += (
59
121
  " Object.assign(process.env, "
60
122
  + json.dumps(settings)
@@ -64,18 +126,17 @@ def install_pi(root, adapter, settings, previous):
64
126
  return {"entry": str(entry), "digest": hashlib.sha256(body.encode()).hexdigest()}
65
127
 
66
128
 
67
- def profile_manifest(binary, platform, profile):
68
- args = [binary, "--profile", profile, "--dump-config"]
69
- tree = run(*args)
129
+ def profile_manifest(command, entry, platform, profile):
130
+ tree = run(*command, "--profile", profile, "--dump-config")
70
131
  info = json.loads(
71
- run("node", platform / "scripts/dsh-web-probe.mjs", binary, profile)
132
+ run("node", platform / "scripts/dsh-web-probe.mjs", entry, profile)
72
133
  )
73
134
  return Path(info["profileDir"]) / "package.json", tree
74
135
 
75
136
 
76
137
  def install_dsh(root, platform, adapter, settings, profile, previous):
77
- binary = shutil.which("dsh")
78
- manifest, tree = profile_manifest(binary, platform, profile)
138
+ command, binary = dsh_command()
139
+ manifest, tree = profile_manifest(command, binary, platform, profile)
79
140
  if previous and previous["manifest"] != str(manifest):
80
141
  raise ValueError(
81
142
  "DSH profile directory changed; existing registration preserved"
@@ -87,15 +148,15 @@ def install_dsh(root, platform, adapter, settings, profile, previous):
87
148
  entry = bundle / "provider.mjs"
88
149
  link = profile_dir / "node_modules" / bundle_name
89
150
  marker = "// Managed by AFU " + root.as_uri() + "\n"
90
- original = manifest.read_text()
151
+ original = manifest.read_text(encoding="utf-8" if WINDOWS() else None)
91
152
  config = json.loads(original)
92
153
  bundles = config["dsh"]["profile"]["bundles"]
93
154
  deps = config.setdefault("dependencies", {})
94
155
  owned_dep = "file:" + str(bundle)
95
- if link.parent.is_symlink():
156
+ if is_link(link.parent):
96
157
  raise ValueError("Cannot register a bundle in a shared node_modules symlink")
97
- if (link.is_symlink() and link.resolve() != bundle) or (
98
- link.exists() and not link.is_symlink()
158
+ if (is_link(link) and link.resolve() != bundle.resolve()) or (
159
+ link.exists() and not is_link(link)
99
160
  ):
100
161
  raise ValueError("Existing DSH bundle directory conflicts with AFU")
101
162
  if bundle_name in deps and deps[bundle_name] != owned_dep:
@@ -115,8 +176,8 @@ def install_dsh(root, platform, adapter, settings, profile, previous):
115
176
  )
116
177
  preset = bundle / "presets.mjs"
117
178
  if preset.exists():
118
- old_arg = ", " + json.dumps(str(bundle)) + ");"
119
- source = preset.read_text()
179
+ old_arg = ", " + json.dumps(str(bundle), ensure_ascii=not WINDOWS()) + ");"
180
+ source = preset.read_text(encoding="utf-8" if WINDOWS() else None)
120
181
  if old_arg not in source:
121
182
  raise ValueError("Unsupported preset loader format")
122
183
  cleanup = (
@@ -142,18 +203,20 @@ def install_dsh(root, platform, adapter, settings, profile, previous):
142
203
  write_owned(entry, content, marker)
143
204
  if not link.exists():
144
205
  link.parent.mkdir(parents=True, exist_ok=True)
145
- link.symlink_to(bundle, target_is_directory=True)
206
+ link_directory(link, bundle)
146
207
  if bundle_name not in bundles:
147
208
  bundles.append(bundle_name)
148
209
  deps[bundle_name] = owned_dep
149
- if manifest.read_text() != original:
210
+ if manifest.read_text(encoding="utf-8" if WINDOWS() else None) != original:
150
211
  raise ValueError("Concurrent DSH configuration change")
151
212
  fs.write(manifest, config)
152
213
  try:
153
- composed = run(binary, "--profile", profile, "--dump-config")
154
- if (
155
- str(entry) not in composed
156
- and str(bundle / "presets.mjs") not in composed
214
+ composed = run(*command, "--profile", profile, "--dump-config")
215
+ # The composed tree may print paths with either separator.
216
+ flat = composed.replace("\\\\", "/").replace("\\", "/")
217
+ if not any(
218
+ (str(p).replace("\\", "/") in flat or (WINDOWS() and p.as_uri() in composed))
219
+ for p in (entry, bundle / "presets.mjs")
157
220
  ):
158
221
  raise ValueError("DSH did not resolve the AFU provider")
159
222
  except Exception:
@@ -194,8 +257,8 @@ def remove_record(host, record):
194
257
  fs.write(manifest, config)
195
258
  # Keep bundle files for running sessions (some presets resolve lazily).
196
259
  link = manifest.parent / "node_modules" / name
197
- if link.is_symlink() and link.resolve() == Path(record["bundle"]):
198
- link.unlink()
260
+ if is_link(link) and link.resolve() == Path(record["bundle"]).resolve():
261
+ unlink_directory(link)
199
262
 
200
263
 
201
264
  def main():
@@ -300,6 +363,7 @@ def main():
300
363
  "window": window,
301
364
  "ratio": ratio,
302
365
  "build": fs.read(platform / "release.json")["build"],
366
+ "version": fs.read(platform / "release.json").get("version"),
303
367
  "autoload": registrations,
304
368
  },
305
369
  )
@@ -1,8 +1,31 @@
1
1
  import {spawnSync} from 'node:child_process';
2
- import {accessSync,statSync,constants,mkdirSync,rmSync,mkdtempSync} from 'node:fs';
3
- import {join,delimiter,resolve} from 'node:path';
2
+ import {accessSync,statSync,constants,mkdirSync,rmSync,mkdtempSync,readFileSync,realpathSync} from 'node:fs';
3
+ import {join,delimiter,resolve,dirname,relative,isAbsolute} from 'node:path';
4
4
  import {homedir} from 'node:os';
5
5
  import {createInterface} from 'node:readline/promises';
6
+ export function hostCommand(host,env=process.env,platform=process.platform){
7
+ if(platform!=='win32')return [host];
8
+ const packages={pi:'@earendil-works/pi-coding-agent',dsh:'@deepseek-ai/dsh'};
9
+ if(!packages[host])return [host];
10
+ for(const dir of (env.PATH??env.Path??'').split(delimiter)){
11
+ for(const ext of ['.exe','.cmd','.bat']){
12
+ const binary=join(dir,host+ext);
13
+ try{if(!statSync(binary).isFile())continue;}catch{continue;}
14
+ if(ext==='.exe')return [binary];
15
+ const folder=dirname(realpathSync(binary));
16
+ for(const base of [join(folder,'node_modules',packages[host]),join(folder,'..',packages[host])]){
17
+ let data;try{data=JSON.parse(readFileSync(join(base,'package.json'),'utf8'));}catch(e){if(e.code==='ENOENT')continue;throw e;}
18
+ const entry=typeof data.bin==='string'?data.bin:data.bin?.[host];
19
+ if(data.name!==packages[host]||typeof entry!=='string')continue;
20
+ const target=realpathSync(resolve(base,entry)),path=relative(realpathSync(base),target);
21
+ if(path==='..'||path.startsWith('..\\')||path.startsWith('../')||isAbsolute(path)||!statSync(target).isFile())throw Error('INVALID_HOST_ENTRY');
22
+ return [process.execPath,target];
23
+ }
24
+ throw Error('HOST_NPM_ENTRY_UNAVAILABLE');
25
+ }
26
+ }
27
+ throw Error('HOST_EXECUTABLE_UNAVAILABLE');
28
+ }
6
29
  export function hostAvailable(host,env=process.env){
7
30
  return (env.PATH??env.Path??'').split(delimiter).some(dir=>['',...(process.platform==='win32'?['.exe','.cmd']:[])].some(ext=>{try{accessSync(join(dir,host+ext),constants.X_OK);return statSync(join(dir,host+ext)).isFile();}catch{return false;}}));
8
31
  }
@@ -22,7 +45,6 @@ export async function guide(args,env=process.env){
22
45
  args=[args[0],host,...args.slice(1)];
23
46
  }
24
47
  if(!hostAvailable(host,env))throw Error('HOST_NOT_FOUND: '+host);
25
- if(process.platform==='win32'&&host==='codex')throw Error('WINDOWS_CODEX_NATIVE_UNSUPPORTED');
26
48
  const at=args.indexOf('--state');const state=at>=0?resolve(args[at+1]):env.AFU_STATE_DIR??join(homedir(),'.local/share/afu-context');
27
49
  mkdirSync(state,{recursive:true,mode:0o700});const check=mkdtempSync(join(state,'.write-check-'));rmSync(check,{recursive:true});
28
50
  if(['claude','codex'].includes(host)&&!args.includes('--no-autostart')){
package/bin/platform.mjs CHANGED
@@ -2,13 +2,13 @@ import {obtainPack} from './prebuilt.mjs';
2
2
  import {runInstall} from './install-process.mjs';
3
3
  /** Host-only dependencies live outside the installed npm package. */
4
4
  import {mkdirSync,readFileSync,writeFileSync,existsSync,rmSync,cpSync,renameSync,realpathSync} from 'node:fs';
5
- import {join,dirname,delimiter} from 'node:path';
5
+ import {join,dirname,delimiter,basename} from 'node:path';
6
6
  import {homedir} from 'node:os';
7
7
 
8
8
  import {spawnSync} from 'node:child_process';
9
9
  import {pathToFileURL} from 'node:url';
10
10
  import {randomUUID} from 'node:crypto';
11
- import {recoverLock} from './runtime.mjs';
11
+ import {recoverLock,importProbeSucceeded} from './runtime.mjs';
12
12
  const read=p=>{try{return JSON.parse(readFileSync(p,'utf8'));}catch{return null;}};
13
13
  export function npmInvocation(env=process.env,platform=process.platform){
14
14
  if(platform!=='win32')return ['npm',[]];
@@ -28,7 +28,7 @@ export async function preparePlatform(root,manifest,args,env=process.env){
28
28
  let target=canonical;
29
29
  const refresh=()=>{const name=read(pointer)?.name;target=typeof name==='string'&&name.startsWith(manifest.build+'-'+host)&&!/[\\/]/.test(name)?join(base,name):canonical;};
30
30
  refresh();
31
- const ready=()=>read(join(target,'platform-ready.json'))?.build===manifest.build && Object.keys(dependencies).every(name=>existsSync(join(target,'node_modules',name,'package.json'))) && spawnSync(process.execPath,['--input-type=module','-e','await import(process.argv[1])',pathToFileURL(join(target,'src/afu_host_integrations',host,'dist/index.js')).href],{env,stdio:'ignore',timeout:10000}).status===0;
31
+ const ready=()=>read(join(target,'platform-ready.json'))?.build===manifest.build && Object.keys(dependencies).every(name=>existsSync(join(target,'node_modules',name,'package.json'))) && importProbeSucceeded(spawnSync(process.execPath,['--input-type=module','-e','await import(process.argv[1])',pathToFileURL(join(target,'src/afu_host_integrations',host,'dist/index.js')).href],{env,stdio:'ignore',timeout:process.platform==='win32'?30000:10000}));
32
32
  mkdirSync(base,{recursive:true,mode:0o700});
33
33
  const deadline=Date.now()+180000;
34
34
  while(true){
@@ -66,7 +66,7 @@ export async function preparePlatform(root,manifest,args,env=process.env){
66
66
  // and corrupt transitive dependencies. No model request is made by imports.
67
67
  await runInstall(process.execPath,['--input-type=module','-e','await import(process.argv[1])',pathToFileURL(join(target,'src/afu_host_integrations',host,'dist/index.js')).href],env);
68
68
  writeFileSync(join(target,'platform-ready.json'),JSON.stringify({build:manifest.build,cache_protocol:1}),{mode:0o600});
69
- const temp=pointer+'.'+token;writeFileSync(temp,JSON.stringify({name:target.slice(base.length+1)}),{mode:0o600});renameSync(temp,pointer);return target;
69
+ const temp=pointer+'.'+token;writeFileSync(temp,JSON.stringify({name:process.platform==='win32'?basename(target):target.slice(base.length+1)}),{mode:0o600});renameSync(temp,pointer);return target;
70
70
  }catch(error){
71
71
  if(owns()&&createdTarget)rmSync(createdTarget,{recursive:true,force:true});
72
72
  throw error;
package/bin/runtime.mjs CHANGED
@@ -33,12 +33,16 @@ export function recoverLock(lock, now=Date.now()) {
33
33
  renameSync(lock,tomb);moved=true;rmSync(tomb,{recursive:true,force:true});return true;
34
34
  }catch{return false;}finally{if(!moved)rmSync(guard,{recursive:true,force:true});}
35
35
  }
36
+ export function importProbeSucceeded(result,platform=process.platform){
37
+ if(platform==='win32'&&result.error?.code==='ETIMEDOUT')throw Error('IMPORT_CHECK_TIMED_OUT: existing cache preserved; retry when the machine is less busy');
38
+ return result.status===0;
39
+ }
36
40
  export function runtimeReady(location, build) {
37
41
  const marker=read(join(location,'afu-ready.json'));
38
42
  const python=join(location,process.platform==='win32'?'Scripts/python.exe':'bin/python');
39
43
  if(marker?.build!==build || !existsSync(python))return false;
40
44
  // A surviving marker is not proof that imports and the interpreter still work.
41
- return spawnSync(python,['-c','import afu_context.local_cli, aiohttp, tiktoken'],{stdio:'ignore',timeout:10000}).status===0;
45
+ return importProbeSucceeded(spawnSync(python,['-c','import afu_context.local_cli, aiohttp, tiktoken'],{stdio:'ignore',timeout:process.platform==='win32'?30000:10000}));
42
46
  }
43
47
  export async function prepareRuntime(root,manifest,env=process.env) {
44
48
  const base=env.AFU_RUNTIME_DIR ?? join(homedir(),'.local/share/afu-context/runtimes');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liwu1995/afu-context",
3
- "version": "0.1.6",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Local asynchronous context compression and diagnostics",
package/release.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.1.6",
3
- "build": "5a12cd02f9fbe6ffbf4989e8ef5932a0456a9deadee5d03af0490f02fabf799a",
4
- "wheel": "afu_context_local-0.1.6-py3-none-any.whl",
2
+ "version": "0.1.11",
3
+ "build": "00a744986d028b488f3f9a6396bf06cb1983e95e293eeae624f93aab1119377b",
4
+ "wheel": "afu_context_local-0.1.11-py3-none-any.whl",
5
5
  "prebuilt": false
6
6
  }
@@ -1,11 +1,16 @@
1
1
  /** Compose a disposable overlay using DSH's own YAML and patch semantics. */
2
2
  import {readFileSync, writeFileSync, realpathSync} from 'node:fs';
3
3
  import {createRequire} from 'node:module';
4
- import {join} from 'node:path';
4
+ import {join,isAbsolute} from 'node:path';
5
5
  import {pathToFileURL} from 'node:url';
6
6
  import {load,dump} from 'js-yaml';
7
7
  import {entryListSchema,applyEntryPatches} from '@deepseek-ai/cordis-plugin-include';
8
8
 
9
+ export function moduleReference(value) {
10
+ // COSEC: convert only local absolute paths, never reinterpret package names or URLs.
11
+ return process.platform === 'win32' && isAbsolute(value) ? pathToFileURL(value).href : value;
12
+ }
13
+
9
14
  export function overlay(entries,module,presetModule) {
10
15
  if (!Array.isArray(entries)) throw Error('Expected a composed DSH entry list');
11
16
  const ids=new Set(), matches=[], presets=[];
@@ -43,10 +48,10 @@ if(process.argv[1] && import.meta.url===pathToFileURL(process.argv[1]).href) {
43
48
  try {
44
49
  const native=requireHost.resolve('@deepseek-ai/dsh-agent-presets');
45
50
  presetModule=join(process.argv[3],'presets.mjs');
46
- writeFileSync(presetModule, `import Native from ${JSON.stringify(pathToFileURL(native).href)};\nimport {presetAdapter} from ${JSON.stringify(new URL('./dsh-presets.mjs',import.meta.url).href)};\nexport default presetAdapter(Native, ${JSON.stringify(process.argv[2])}, ${JSON.stringify(process.argv[3])});\n`, {mode:0o600});
51
+ writeFileSync(presetModule, `import Native from ${JSON.stringify(pathToFileURL(native).href)};\nimport {presetAdapter} from ${JSON.stringify(new URL('./dsh-presets.mjs',import.meta.url).href)};\nexport default presetAdapter(Native, ${JSON.stringify(moduleReference(process.argv[2]))}, ${JSON.stringify(process.argv[3])});\n`, {mode:0o600});
47
52
  } catch (error) { if(error.code!=='MODULE_NOT_FOUND') throw error; }
48
53
  }
49
- const patches=overlay(tree,process.argv[2],presetModule);
54
+ const patches=overlay(tree,moduleReference(process.argv[2]),presetModule && moduleReference(presetModule));
50
55
  if (process.argv[5]==='web-compat') {
51
56
  const picker=tree.find(row=>!row.disabled && row.name==='@deepseek-ai/dsh-host-directory-picker-auto');
52
57
  if (picker) {
@@ -1,6 +1,6 @@
1
1
  import { BasicCompactionEngine, } from "@deepseek-ai/dsh-compaction-basic";
2
2
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
3
- import { journal, sessionHash, summaryPrefix, observationId, HostCompactPool, bindingKey, estimateContentTokens, findAppliedArtifact, normalizeContent, } from "../../shared/runtime-client/dist/index.js";
3
+ import { journal, sessionHash, afuTrigger, summaryPrefix, observationId, HostCompactPool, bindingKey, estimateContentTokens, findAppliedArtifact, normalizeContent, } from "../../shared/runtime-client/dist/index.js";
4
4
  /**
5
5
  * DSH-native compaction provider. Idle turns prepare Compact Artifacts off-path;
6
6
  * the next pre-step commits only exact, still-contiguous coverage through DSH's
@@ -48,7 +48,17 @@ export class CompactDshEngine extends BasicCompactionEngine {
48
48
  ctx.on("agent/status", async ({ agent, status }) => {
49
49
  if (status !== "idle")
50
50
  return;
51
- this.log("request_observed", { session: sessionHash(agent.session.id), phase: "host_turn_idle" });
51
+ // Context size as DSH's own token meter prices it, plus the AFU trigger for the routed model.
52
+ let tokens, window;
53
+ tokens = dshContextTokens(ctx, agent);
54
+ try {
55
+ window = (await dshHostBinding(ctx, agent, () => this.generation)).contextWindow;
56
+ }
57
+ catch {
58
+ window = undefined;
59
+ }
60
+ this.log("request_observed", { session: sessionHash(agent.session.id), phase: "host_turn_idle",
61
+ ...(tokens === undefined ? {} : { estimated_input_tokens: tokens }), ...afuTrigger(window) });
52
62
  try {
53
63
  const request = await this.boundRequest(agent, agent.session.deriveMessages(), "sync");
54
64
  this.idlePrecomputer.schedule(request);
@@ -58,7 +68,9 @@ export class CompactDshEngine extends BasicCompactionEngine {
58
68
  }
59
69
  });
60
70
  ctx.effect(() => async () => {
61
- this.log("runtime_stopped");
71
+ // DSH creates and disposes an engine for every agent scope (the web app
72
+ // does so per request); the process lives on. runtime_stopped is logged
73
+ // once, when the process exits (see telemetry.journal).
62
74
  // Stop scheduling first so jobs cancelled by shutdown are not reported
63
75
  // as preparation failures, then stop the host and let them settle.
64
76
  this.idlePrecomputer.stop();
@@ -78,7 +90,9 @@ export class CompactDshEngine extends BasicCompactionEngine {
78
90
  if (!this.bindings.has(binding.key) && this.bindings.size >= 16)
79
91
  throw new Error("DSH binding limit reached");
80
92
  this.bindings.set(binding.key, binding);
81
- return dshMessagesToRequest(messages, binding.key, mode);
93
+ const request = dshMessagesToRequest(messages, binding.key, mode);
94
+ const tokens = dshContextTokens(this.ctx, agent);
95
+ return tokens === undefined ? request : { ...request, metadata: { ...request.metadata, host_context_tokens: tokens } };
82
96
  }
83
97
  async compactIfNeeded(agent, trigger, signal) {
84
98
  const started = performance.now();
@@ -234,6 +248,17 @@ export async function dshHostBinding(ctx, agent, generation) {
234
248
  },
235
249
  };
236
250
  }
251
+ /** DSH's own token meter (the measure its native compaction uses), if available. */
252
+ function dshContextTokens(ctx, agent) {
253
+ try {
254
+ const meter = ctx.tokenMeter;
255
+ const tokens = meter?.measure(agent.session).totalTokens;
256
+ return Number.isSafeInteger(tokens) && tokens > 0 ? tokens : undefined;
257
+ }
258
+ catch {
259
+ return undefined;
260
+ }
261
+ }
237
262
  export function dshMessagesToRequest(messages, sessionId, mode = "active") {
238
263
  const groups = toolCallGroups(messages);
239
264
  const items = messages.map((message) => {