@span-io/agent-link 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -72,7 +72,12 @@ Configuration is stored in `~/.config/remote-agent/client.json`.
72
72
  **Environment Variables:**
73
73
  * `CODEX_BIN`, `GEMINI_BIN`, `CLAUDE_BIN`: Override the path to specific agent binaries.
74
74
  * `CODEX_CWD`: Set the working directory for the agent (defaults to the directory where you ran the client).
75
+ * `AGENT_LINK_PROMPT_CHAR_LIMIT` (or `CODEX_PROMPT_CHAR_LIMIT`): Hard cap for prompt length in characters (default `200000`).
76
+ * `AGENT_LINK_PROMPT_CHAR_THRESHOLD` (or `CODEX_PROMPT_CHAR_THRESHOLD`): Length at which compaction kicks in (default `90%` of limit).
77
+ * `AGENT_LINK_PROMPT_CHAR_TARGET` (or `CODEX_PROMPT_CHAR_TARGET`): Target length after compaction (default `85%` of limit).
78
+ * `AGENT_LINK_PROMPT_COMPACT` (or `CODEX_PROMPT_COMPACT`): `auto`, `summary`, `truncate`, or `off` (default `auto`).
79
+ * `AGENT_LINK_PROMPT_SUMMARY_LINES` (or `CODEX_PROMPT_SUMMARY_LINES`): Max lines to include in heuristic summary (default `20`).
75
80
 
76
81
  ## License
77
82
 
78
- MIT
83
+ MIT
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { loadConfig, saveConfig } from "./config.js";
5
5
  import { findAgentsOnPath, resolveAgentBinary } from "./agent.js";
6
6
  import { spawnAgentProcess as spawnAgentProcessAdvanced } from "./process-runner.js";
7
7
  import { LogBuffer } from "./log-buffer.js";
8
+ import { compactPrompt, resolvePromptCompactionPolicy } from "./prompt-compact.js";
8
9
  import { NoopTransport, WebSocketTransport } from "./transport.js";
9
10
  const args = parseArgs(process.argv.slice(2));
10
11
  if (args.list) {
@@ -43,6 +44,7 @@ if (args.pairingCode) {
43
44
  const logBuffer = new LogBuffer();
44
45
  // Update map to hold SpawnedProcess which includes the child
45
46
  const activeAgents = new Map();
47
+ const promptPolicy = resolvePromptCompactionPolicy();
46
48
  let transport;
47
49
  try {
48
50
  transport = await createTransport({
@@ -67,7 +69,11 @@ function handleControl(message) {
67
69
  let agentProc = activeAgents.get(agentId);
68
70
  if (agentProc) {
69
71
  if (payload?.prompt && agentProc.child.stdin) {
70
- agentProc.child.stdin.write(`${payload.prompt}\n`);
72
+ const normalized = compactPrompt(payload.prompt, promptPolicy);
73
+ if (normalized.action !== "none") {
74
+ console.warn(`Prompt ${normalized.action} (${normalized.reason ?? "threshold"}): ${normalized.originalLength} -> ${normalized.finalLength} chars.`);
75
+ }
76
+ agentProc.child.stdin.write(`${normalized.prompt}\n`);
71
77
  }
72
78
  return;
73
79
  }
@@ -110,13 +116,18 @@ function handleControl(message) {
110
116
  return;
111
117
  }
112
118
  const optionsArgs = [...(payload?.args || args.agentArgs)];
119
+ const rawPrompt = payload?.prompt || "";
120
+ const normalized = compactPrompt(rawPrompt, promptPolicy);
121
+ if (normalized.action !== "none") {
122
+ console.warn(`Prompt ${normalized.action} (${normalized.reason ?? "threshold"}): ${normalized.originalLength} -> ${normalized.finalLength} chars.`);
123
+ }
113
124
  const proc = spawnAgentProcessAdvanced({
114
125
  agent: {
115
126
  id: agentId,
116
127
  name: agentCandidate.name,
117
128
  model: targetModel || "codex-cli"
118
129
  },
119
- prompt: payload?.prompt || "",
130
+ prompt: normalized.prompt,
120
131
  optionsArgs,
121
132
  executablePath: agentCandidate.path
122
133
  });
@@ -139,7 +150,11 @@ function handleControl(message) {
139
150
  const proc = activeAgents.get(agentId);
140
151
  const data = message.data || payload?.prompt;
141
152
  if (proc && data && proc.child.stdin) {
142
- proc.child.stdin.write(data.endsWith("\n") ? data : `${data}\n`);
153
+ const normalized = compactPrompt(data, promptPolicy);
154
+ if (normalized.action !== "none") {
155
+ console.warn(`Prompt ${normalized.action} (${normalized.reason ?? "threshold"}): ${normalized.originalLength} -> ${normalized.finalLength} chars.`);
156
+ }
157
+ proc.child.stdin.write(normalized.prompt.endsWith("\n") ? normalized.prompt : `${normalized.prompt}\n`);
143
158
  }
144
159
  break;
145
160
  }
@@ -1,3 +1,4 @@
1
+ import { mkdirSync } from "fs";
1
2
  import { spawn } from "child_process";
2
3
  const DEFAULT_CODEX_ARGS = "exec --skip-git-repo-check";
3
4
  const DEFAULT_GEMINI_ARGS = "";
@@ -12,6 +13,50 @@ function shellQuote(value) {
12
13
  // Simple single-quote wrapping for display purposes
13
14
  return `'${value.replace(/'/g, `'"'"'`)}'`;
14
15
  }
16
+ function collectDirectoriesFromArgs(args) {
17
+ const directories = [];
18
+ for (let i = 0; i < args.length; i += 1) {
19
+ const arg = args[i];
20
+ const next = args[i + 1];
21
+ if ((arg === "--cd" || arg === "-C" || arg === "--add-dir" || arg === "--include-directories") && typeof next === "string") {
22
+ directories.push(next);
23
+ i += 1;
24
+ continue;
25
+ }
26
+ if (arg.startsWith("--cd=")) {
27
+ directories.push(arg.slice("--cd=".length));
28
+ continue;
29
+ }
30
+ if (arg.startsWith("--add-dir=")) {
31
+ directories.push(arg.slice("--add-dir=".length));
32
+ continue;
33
+ }
34
+ if (arg.startsWith("--include-directories=")) {
35
+ const raw = arg.slice("--include-directories=".length);
36
+ raw
37
+ .split(",")
38
+ .map((entry) => entry.trim())
39
+ .filter(Boolean)
40
+ .forEach((entry) => directories.push(entry));
41
+ continue;
42
+ }
43
+ }
44
+ return directories;
45
+ }
46
+ function ensureDirectoriesExist(rawDirs) {
47
+ const deduped = new Set(rawDirs
48
+ .map((entry) => entry.trim())
49
+ .filter((entry) => entry.length > 0));
50
+ for (const dir of deduped) {
51
+ try {
52
+ mkdirSync(dir, { recursive: true });
53
+ }
54
+ catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ throw new Error(`Failed to create working directory '${dir}': ${message}`);
57
+ }
58
+ }
59
+ }
15
60
  export function buildCommand({ agent, prompt, optionsArgs = [], executablePath }, promptModeOverride) {
16
61
  // 1. Gemini Models
17
62
  if (agent.model.startsWith("gemini-")) {
@@ -34,7 +79,13 @@ export function buildCommand({ agent, prompt, optionsArgs = [], executablePath }
34
79
  // 2. Claude Models
35
80
  if (agent.model.startsWith("claude-")) {
36
81
  const command = executablePath ?? process.env.CLAUDE_BIN ?? "claude";
37
- const args = ["-p", prompt, "--model", agent.model];
82
+ const args = ["-p", prompt];
83
+ if (optionsArgs.length > 0) {
84
+ args.push(...optionsArgs);
85
+ }
86
+ if (!args.includes("--model") && !args.includes("-m")) {
87
+ args.push("--model", agent.model);
88
+ }
38
89
  return { command, args, promptMode: "args" };
39
90
  }
40
91
  // 3. Generic Codex/Other Models
@@ -81,11 +132,11 @@ export function spawnAgentProcess(config) {
81
132
  const { agent, optionsArgs = [] } = config;
82
133
  const startedAt = new Date().toISOString();
83
134
  const isCodexModel = !agent.model.startsWith("gemini-") && !agent.model.startsWith("claude-");
84
- const sanitizedOptions = agent.model.startsWith("claude-")
85
- ? []
86
- : optionsArgs;
135
+ const sanitizedOptions = optionsArgs;
87
136
  const spawnWithMode = (promptModeOverride) => {
88
137
  const { command, args, promptMode } = buildCommand({ ...config, optionsArgs: sanitizedOptions }, promptModeOverride);
138
+ // Ensure requested project/working directories exist before launching CLI.
139
+ ensureDirectoriesExist(collectDirectoriesFromArgs(args));
89
140
  const ttyMode = process.env.CODEX_TTY_MODE ?? DEFAULT_TTY_MODE;
90
141
  const hasExec = args.includes("exec");
91
142
  const useScriptWrapper = ttyMode === "script" || (ttyMode === "auto" && hasExec);
@@ -0,0 +1,175 @@
1
+ const DEFAULT_MAX_CHARS = 200_000;
2
+ const DEFAULT_THRESHOLD_RATIO = 0.9;
3
+ const DEFAULT_TARGET_RATIO = 0.85;
4
+ const DEFAULT_SUMMARY_MAX_LINES = 20;
5
+ function parseEnvInt(value) {
6
+ if (!value)
7
+ return null;
8
+ const parsed = Number.parseInt(value, 10);
9
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
10
+ }
11
+ function parseEnvMode(value) {
12
+ if (!value)
13
+ return null;
14
+ const normalized = value.trim().toLowerCase();
15
+ if (normalized === "auto" ||
16
+ normalized === "summary" ||
17
+ normalized === "truncate" ||
18
+ normalized === "off") {
19
+ return normalized;
20
+ }
21
+ return null;
22
+ }
23
+ function clamp(value, min, max) {
24
+ return Math.max(min, Math.min(max, value));
25
+ }
26
+ function resolveEnvValue(env, keys) {
27
+ for (const key of keys) {
28
+ const value = env[key];
29
+ if (value !== undefined)
30
+ return value;
31
+ }
32
+ return undefined;
33
+ }
34
+ export function resolvePromptCompactionPolicy(env = process.env) {
35
+ const maxChars = parseEnvInt(resolveEnvValue(env, ["AGENT_LINK_PROMPT_CHAR_LIMIT", "CODEX_PROMPT_CHAR_LIMIT"])) ??
36
+ DEFAULT_MAX_CHARS;
37
+ const thresholdCharsEnv = parseEnvInt(resolveEnvValue(env, ["AGENT_LINK_PROMPT_CHAR_THRESHOLD", "CODEX_PROMPT_CHAR_THRESHOLD"]));
38
+ const targetCharsEnv = parseEnvInt(resolveEnvValue(env, ["AGENT_LINK_PROMPT_CHAR_TARGET", "CODEX_PROMPT_CHAR_TARGET"]));
39
+ const thresholdChars = thresholdCharsEnv ?? Math.floor(maxChars * DEFAULT_THRESHOLD_RATIO);
40
+ const targetChars = targetCharsEnv ?? Math.floor(maxChars * DEFAULT_TARGET_RATIO);
41
+ const mode = parseEnvMode(resolveEnvValue(env, ["AGENT_LINK_PROMPT_COMPACT", "CODEX_PROMPT_COMPACT"])) ??
42
+ "auto";
43
+ const summaryMaxLines = parseEnvInt(resolveEnvValue(env, ["AGENT_LINK_PROMPT_SUMMARY_LINES", "CODEX_PROMPT_SUMMARY_LINES"])) ??
44
+ DEFAULT_SUMMARY_MAX_LINES;
45
+ return {
46
+ maxChars,
47
+ thresholdChars: clamp(thresholdChars, 1, maxChars),
48
+ targetChars: clamp(targetChars, 1, maxChars),
49
+ mode,
50
+ summaryMaxLines,
51
+ };
52
+ }
53
+ function truncateByPreservingEdges(prompt, targetChars) {
54
+ if (prompt.length <= targetChars)
55
+ return prompt;
56
+ const marker = "\n\n[...prompt truncated...]\n\n";
57
+ if (targetChars <= marker.length + 1) {
58
+ return prompt.slice(0, targetChars);
59
+ }
60
+ const available = targetChars - marker.length;
61
+ const headLen = Math.floor(available * 0.6);
62
+ const tailLen = available - headLen;
63
+ return `${prompt.slice(0, headLen)}${marker}${prompt.slice(prompt.length - tailLen)}`;
64
+ }
65
+ function extractKeyLines(text, maxLines) {
66
+ const lines = text.split(/\r?\n/);
67
+ const picked = [];
68
+ const seen = new Set();
69
+ for (const rawLine of lines) {
70
+ const line = rawLine.trim();
71
+ if (line.length === 0)
72
+ continue;
73
+ const isHeading = /^(#+\s+|[*-]\s+|[A-Z][\w\s-]{0,40}:)/.test(line);
74
+ const isSignal = /(ERROR|WARN|WARNING|TODO|FIXME|NOTE|IMPORTANT)/i.test(line);
75
+ if (!isHeading && !isSignal)
76
+ continue;
77
+ if (line.length > 160)
78
+ continue;
79
+ if (seen.has(line))
80
+ continue;
81
+ seen.add(line);
82
+ picked.push(line);
83
+ if (picked.length >= maxLines)
84
+ break;
85
+ }
86
+ return picked;
87
+ }
88
+ function buildSummary(removed, maxChars, maxLines) {
89
+ if (maxChars <= 0)
90
+ return "";
91
+ const header = "SUMMARY OF REMOVED CONTENT:\n";
92
+ const lines = extractKeyLines(removed, maxLines);
93
+ const body = lines.length > 0
94
+ ? lines.map((line) => `- ${line}`).join("\n")
95
+ : "Summary unavailable; content removed.";
96
+ let summary = `${header}${body}`;
97
+ if (summary.length > maxChars) {
98
+ summary = summary.slice(0, Math.max(0, maxChars - 3)).trimEnd() + "...";
99
+ }
100
+ return summary;
101
+ }
102
+ function compactWithSummary(prompt, targetChars, maxLines) {
103
+ if (prompt.length <= targetChars)
104
+ return prompt;
105
+ const marker = "\n\n[...prompt compacted; middle summarized...]\n\n";
106
+ const spacer = "\n\n";
107
+ let headBudget = Math.floor(targetChars * 0.3);
108
+ let tailBudget = Math.floor(targetChars * 0.3);
109
+ let summaryBudget = targetChars - headBudget - tailBudget - marker.length - spacer.length;
110
+ if (summaryBudget < 50) {
111
+ return truncateByPreservingEdges(prompt, targetChars);
112
+ }
113
+ const head = prompt.slice(0, headBudget);
114
+ const tail = prompt.slice(prompt.length - tailBudget);
115
+ const removed = prompt.slice(headBudget, prompt.length - tailBudget);
116
+ const summary = buildSummary(removed, summaryBudget, maxLines);
117
+ const compacted = `${head}${marker}${summary}${spacer}${tail}`;
118
+ if (compacted.length <= targetChars) {
119
+ return compacted;
120
+ }
121
+ return truncateByPreservingEdges(compacted, targetChars);
122
+ }
123
+ export function compactPrompt(prompt, policy) {
124
+ const originalLength = prompt.length;
125
+ if (originalLength === 0) {
126
+ return { prompt, originalLength, finalLength: 0, action: "none" };
127
+ }
128
+ const maxChars = Math.max(1, policy.maxChars);
129
+ const thresholdChars = clamp(policy.thresholdChars, 1, maxChars);
130
+ const targetChars = clamp(policy.targetChars, 1, maxChars);
131
+ if (originalLength <= thresholdChars) {
132
+ return { prompt, originalLength, finalLength: originalLength, action: "none" };
133
+ }
134
+ if (policy.mode === "off") {
135
+ if (originalLength <= maxChars) {
136
+ return { prompt, originalLength, finalLength: originalLength, action: "none" };
137
+ }
138
+ const truncated = truncateByPreservingEdges(prompt, maxChars);
139
+ return {
140
+ prompt: truncated,
141
+ originalLength,
142
+ finalLength: truncated.length,
143
+ action: "truncate",
144
+ reason: "hard-limit",
145
+ };
146
+ }
147
+ if (policy.mode === "truncate") {
148
+ const truncated = truncateByPreservingEdges(prompt, targetChars);
149
+ return {
150
+ prompt: truncated,
151
+ originalLength,
152
+ finalLength: truncated.length,
153
+ action: "truncate",
154
+ reason: "policy-truncate",
155
+ };
156
+ }
157
+ const summarized = compactWithSummary(prompt, targetChars, policy.summaryMaxLines);
158
+ if (summarized.length <= targetChars) {
159
+ return {
160
+ prompt: summarized,
161
+ originalLength,
162
+ finalLength: summarized.length,
163
+ action: "summary",
164
+ reason: "policy-summary",
165
+ };
166
+ }
167
+ const truncated = truncateByPreservingEdges(summarized, targetChars);
168
+ return {
169
+ prompt: truncated,
170
+ originalLength,
171
+ finalLength: truncated.length,
172
+ action: "truncate",
173
+ reason: "summary-overflow",
174
+ };
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@span-io/agent-link",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Secure bridge between Span (AI control plane) and local agent CLI tools.",
5
5
  "type": "module",
6
6
  "bin": {