@agentprojectcontext/apx 1.66.0 → 1.67.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 (49) hide show
  1. package/package.json +3 -2
  2. package/skills/apx/SKILL.md +3 -0
  3. package/src/core/agent/index.js +2 -0
  4. package/src/core/agent/judge.js +174 -0
  5. package/src/core/agent/model-router.js +107 -5
  6. package/src/core/agent/prompts/modes/code-build.md +1 -1
  7. package/src/core/agent/run-agent.js +149 -12
  8. package/src/core/agent/security.js +97 -0
  9. package/src/core/agent/stuck-detector.js +89 -0
  10. package/src/core/agent/super-agent.js +58 -17
  11. package/src/core/agent/tools/handlers/run-subagent.js +117 -0
  12. package/src/core/agent/tools/helpers.js +11 -1
  13. package/src/core/agent/tools/names.js +2 -0
  14. package/src/core/agent/tools/registry.js +10 -0
  15. package/src/core/artifacts/preview.js +392 -0
  16. package/src/core/artifacts/tunnel.js +169 -0
  17. package/src/core/config/index.js +61 -0
  18. package/src/core/config/secret-values.js +132 -0
  19. package/src/core/engines/mock.js +15 -1
  20. package/src/core/logging.js +10 -3
  21. package/src/core/memory/compactor.js +65 -56
  22. package/src/core/memory/summarizer.js +125 -0
  23. package/src/core/stores/conversations-compactor.js +24 -31
  24. package/src/host/daemon/api/admin-config.js +5 -0
  25. package/src/host/daemon/api/artifact-preview.js +82 -0
  26. package/src/host/daemon/api/web.js +1 -1
  27. package/src/host/daemon/api.js +2 -0
  28. package/src/host/daemon/index.js +16 -1
  29. package/src/interfaces/acp/index.js +363 -0
  30. package/src/interfaces/acp/jsonrpc.js +180 -0
  31. package/src/interfaces/acp/session.js +205 -0
  32. package/src/interfaces/cli/commands/acp.js +10 -0
  33. package/src/interfaces/cli/commands/artifact.js +115 -0
  34. package/src/interfaces/cli/index.js +74 -0
  35. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +803 -0
  36. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +1 -0
  37. package/src/interfaces/web/dist/assets/index-BPGECxzm.css +1 -0
  38. package/src/interfaces/web/dist/index.html +2 -2
  39. package/src/interfaces/web/package-lock.json +6 -6
  40. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
  41. package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
  42. package/src/interfaces/web/src/i18n/en.ts +47 -0
  43. package/src/interfaces/web/src/i18n/es.ts +47 -0
  44. package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
  45. package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
  46. package/src/interfaces/web/src/types/daemon.ts +16 -0
  47. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
  48. package/src/interfaces/web/dist/assets/index-YmMRG--4.js +0 -778
  49. package/src/interfaces/web/dist/assets/index-YmMRG--4.js.map +0 -1
@@ -0,0 +1,132 @@
1
+ // Value-based secret masking (OpenHands-inspired). redact.js masks secrets by
2
+ // KEY (a config view knows "engines.openai.api_key" is secret); this module
3
+ // masks by VALUE: known secret strings are registered once (daemon boot +
4
+ // config hot-reload) and then scrubbed from ANY log text they leak into —
5
+ // free-text error messages, provider echoes, tool output captured in traces.
6
+ //
7
+ // Both layers stay on: key-based redaction catches secrets in structured meta
8
+ // even before registration; value-based masking catches them everywhere else.
9
+
10
+ import { SECRET_PATHS } from "./redact.js";
11
+
12
+ // Never register strings shorter than this — masking "abc" would shred every
13
+ // log line containing those three letters.
14
+ const MIN_SECRET_LENGTH = 6;
15
+
16
+ // Same key heuristic as SECRET_KEY_RE in core/logging.js — used to decide
17
+ // which MCP env/header entries hold secrets (env also carries harmless values
18
+ // like NODE_ENV whose masking would mangle unrelated log text).
19
+ const SECRET_ENTRY_KEY_RE = /(token|secret|password|api[_-]?key|authorization|credential)/i;
20
+
21
+ // Module-level registry. Additive on purpose: a hot-reload that removes a key
22
+ // keeps the old value masked — stale masking is harmless, a leak is not.
23
+ const registry = new Set();
24
+
25
+ function isRegistrable(value) {
26
+ return typeof value === "string" && value.trim().length >= MIN_SECRET_LENGTH;
27
+ }
28
+
29
+ function getDotted(obj, dotted) {
30
+ let cur = obj;
31
+ for (const part of dotted.split(".")) {
32
+ if (!cur || typeof cur !== "object") return undefined;
33
+ cur = cur[part];
34
+ }
35
+ return cur;
36
+ }
37
+
38
+ /**
39
+ * Walk a config object and return every secret VALUE it holds: all
40
+ * SECRET_PATHS entries, every telegram channel bot_token, and the legacy
41
+ * root telegram.bot_token (pre-migration configs). Deduped; empty and
42
+ * too-short strings dropped.
43
+ */
44
+ export function collectSecretValues(cfg) {
45
+ const out = new Set();
46
+ try {
47
+ if (!cfg || typeof cfg !== "object") return [];
48
+ for (const dotted of SECRET_PATHS) {
49
+ if (dotted.includes("*")) continue; // array paths handled below
50
+ const val = getDotted(cfg, dotted);
51
+ if (isRegistrable(val)) out.add(val);
52
+ }
53
+ const channels = cfg?.telegram?.channels;
54
+ if (Array.isArray(channels)) {
55
+ for (const ch of channels) {
56
+ if (isRegistrable(ch?.bot_token)) out.add(ch.bot_token);
57
+ }
58
+ }
59
+ if (isRegistrable(cfg?.telegram?.bot_token)) out.add(cfg.telegram.bot_token);
60
+ } catch {
61
+ // collection must never break the caller — return what we got so far
62
+ }
63
+ return Array.from(out);
64
+ }
65
+
66
+ /**
67
+ * Extract secret values from an mcps.json shape ({ mcpServers: {name: {env,
68
+ * headers}} }) — runtime/global MCP stores carry tokens in env vars and HTTP
69
+ * headers. Only entries whose KEY looks secret are taken (see regex above).
70
+ */
71
+ export function collectMcpSecretValues(mcpsJson) {
72
+ const out = new Set();
73
+ try {
74
+ const servers = mcpsJson?.mcpServers;
75
+ if (!servers || typeof servers !== "object") return [];
76
+ for (const server of Object.values(servers)) {
77
+ for (const bag of [server?.env, server?.headers]) {
78
+ if (!bag || typeof bag !== "object") continue;
79
+ for (const [key, val] of Object.entries(bag)) {
80
+ if (SECRET_ENTRY_KEY_RE.test(key) && isRegistrable(val)) out.add(val);
81
+ }
82
+ }
83
+ }
84
+ } catch {
85
+ // same never-throw contract as collectSecretValues
86
+ }
87
+ return Array.from(out);
88
+ }
89
+
90
+ /** Add values to the module-level registry. Invalid/short entries ignored. */
91
+ export function registerSecretValues(values) {
92
+ if (!Array.isArray(values)) return;
93
+ for (const v of values) {
94
+ if (isRegistrable(v)) registry.add(v);
95
+ }
96
+ }
97
+
98
+ export function getRegisteredSecretValues() {
99
+ return Array.from(registry);
100
+ }
101
+
102
+ /** Test hook — empties the registry so cases don't bleed into each other. */
103
+ export function clearRegisteredSecretValues() {
104
+ registry.clear();
105
+ }
106
+
107
+ // Visible marker in the same spirit as secretMarker() in redact.js (keep a
108
+ // short suffix so the user can tell WHICH secret leaked), but compact enough
109
+ // to live inline in a log line.
110
+ function maskedMarker(value) {
111
+ return `***…${value.slice(-4)}`;
112
+ }
113
+
114
+ /**
115
+ * Replace every registered secret value found in `text` with its marker.
116
+ * Longest-first so a secret that contains another is masked whole instead of
117
+ * being shredded by the shorter one. Non-string input is returned unchanged;
118
+ * this function NEVER throws.
119
+ */
120
+ export function maskSecretValues(text) {
121
+ try {
122
+ if (typeof text !== "string" || !text || registry.size === 0) return text;
123
+ const values = Array.from(registry).sort((a, b) => b.length - a.length);
124
+ let out = text;
125
+ for (const value of values) {
126
+ if (out.includes(value)) out = out.split(value).join(maskedMarker(value));
127
+ }
128
+ return out;
129
+ } catch {
130
+ return text;
131
+ }
132
+ }
@@ -31,11 +31,14 @@ export default {
31
31
  raw: { model, mock: true },
32
32
  };
33
33
  }
34
+ // `[mock:risk:HIGH]` → the emitted tool call carries a security_risk grade,
35
+ // so the inline security analyzer / confirmation gate can be exercised.
36
+ const riskGrade = userText.match(/\[mock:risk:(LOW|MEDIUM|HIGH|UNKNOWN)\]/)?.[1];
34
37
  const mkToolCall = (name, id) => {
35
38
  const toolCall = {
36
39
  id,
37
40
  type: "function",
38
- function: { name, arguments: "{}" },
41
+ function: { name, arguments: riskGrade ? JSON.stringify({ security_risk: riskGrade }) : "{}" },
39
42
  };
40
43
  return {
41
44
  text: "",
@@ -66,6 +69,17 @@ export default {
66
69
  if (loopTool && toolsAvailable) {
67
70
  return mkToolCall(loopTool, "mock-loop-1");
68
71
  }
72
+ // `[mock:loopany:<tool>]` → like loop, but sticky: matched against ANY
73
+ // user turn, so the model keeps looping even after the agent loop injects
74
+ // in-band user notes (exercises stuck-detection escalation, which needs a
75
+ // model that ignores the nudge).
76
+ const loopAnyTool = messages
77
+ .filter((m) => m.role === "user")
78
+ .map((m) => String(m.content || "").match(/\[mock:loopany:([a-z_]+)\]/)?.[1])
79
+ .find(Boolean);
80
+ if (loopAnyTool && toolsAvailable) {
81
+ return mkToolCall(loopAnyTool, "mock-loopany-1");
82
+ }
69
83
  if (requestedTool && !hasToolResult && toolsAvailable) {
70
84
  return mkToolCall(requestedTool, "mock-call-1");
71
85
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { APX_HOME } from "./config/index.js";
4
+ import { maskSecretValues } from "./config/secret-values.js";
4
5
 
5
6
  export const LOG_DIR = path.join(APX_HOME, "logs");
6
7
  export const ERROR_TRACE_PATH = path.join(LOG_DIR, "errors.jsonl");
@@ -32,7 +33,10 @@ export function appendErrorTrace(record) {
32
33
  ts: new Date().toISOString(),
33
34
  ...redact(record),
34
35
  };
35
- fs.appendFileSync(ERROR_TRACE_PATH, JSON.stringify(entry) + "\n", "utf8");
36
+ // Second layer: registered secret VALUES are scrubbed from the serialized
37
+ // record too — error strings often echo a key the key-based redact above
38
+ // can't see (it only inspects object keys, not free text).
39
+ fs.appendFileSync(ERROR_TRACE_PATH, maskSecretValues(JSON.stringify(entry)) + "\n", "utf8");
36
40
  }
37
41
 
38
42
  export function previewText(text, max = 500) {
@@ -68,10 +72,13 @@ export function formatLogLine(level, module, message, meta) {
68
72
  ? String(level).toUpperCase()
69
73
  : "INFO";
70
74
  const mod = String(module || "apx").slice(0, 24);
71
- const msg = String(message ?? "").replace(/\n/g, " ");
75
+ // Key-based redact (meta) + value-based mask (message AND meta): a secret
76
+ // value embedded in a free-text message or under an innocuous meta key
77
+ // ("detail", "stderr") only the value registry can catch.
78
+ const msg = maskSecretValues(String(message ?? "").replace(/\n/g, " "));
72
79
  let suffix = "";
73
80
  if (meta && typeof meta === "object" && Object.keys(meta).length > 0) {
74
- try { suffix = " " + JSON.stringify(redact(meta)); }
81
+ try { suffix = " " + maskSecretValues(JSON.stringify(redact(meta))); }
75
82
  catch { suffix = " {meta:unserializable}"; }
76
83
  }
77
84
  return `[${fmtTs()}] [${lvl.padEnd(5)}] [${mod}] ${msg}${suffix}`;
@@ -1,11 +1,18 @@
1
- // Progressive history compaction (Pieza 3).
1
+ // Progressive history compaction (Pieza 3) — condenser v2.
2
2
  //
3
3
  // When a channel chat accumulates more than `maxTurns` (60) conversational
4
4
  // turns in the rolling window, the oldest turns beyond `keepRecent` (40) are
5
- // collapsed into a dense summary by a light LLM (ollama:gemma2 → haiku
6
- // fallback) and persisted as a `type:"compact"` record in the channel JSONL.
5
+ // collapsed by a light LLM into a STRUCTURED STATE summary (OpenHands
6
+ // LLMSummarizingCondenser mechanics) and persisted as a `type:"compact"`
7
+ // record in the channel JSONL. Two condenser behaviors on top of the original
8
+ // narrative recap:
9
+ // - previous-summary threading: the latest compact record (if any) is fed
10
+ // into the prompt as the FIRST event so the new summary subsumes it —
11
+ // state tracked across compactions never silently drops;
12
+ // - keep_first: the conversation's opening turns (original goal) get special
13
+ // treatment — see the keep_first comment in compactChannelIfNeeded.
7
14
  //
8
- // The reader (getRecentChannelTurnsFromFs) then prepends that summary as a
15
+ // The reader (getRecentChannelTurnsFromFs) then prepends the summary as a
9
16
  // [RESUMEN COMPACTADO] system turn and drops the raw turns it covers, keeping
10
17
  // the model context bounded while preserving decisions / tasks / tool results.
11
18
  //
@@ -17,37 +24,18 @@ import fs from "node:fs";
17
24
  import path from "node:path";
18
25
  import { GLOBAL_MESSAGES_DIR } from "../config/index.js";
19
26
  import { parseDayJsonl, appendGlobalMessage } from "../stores/messages.js";
20
- import { callEngine } from "../engines/index.js";
27
+ import {
28
+ resolveCompactModels,
29
+ buildCondenserPrompt,
30
+ summarizeStructured,
31
+ } from "./summarizer.js";
32
+
33
+ // Re-export so existing importers (tests, callers) keep working.
34
+ export { resolveCompactModels };
21
35
 
22
36
  const DEFAULT_MAX_TURNS = 60;
23
37
  const DEFAULT_KEEP_RECENT = 40;
24
- const COMPACT_MAX_TOKENS = 1000; // ~800-token target + headroom
25
-
26
- const COMPACT_SYSTEM =
27
- "Compactás conversaciones para continuidad de contexto de un agente. " +
28
- "Otro modelo va a leer esto para seguir el trabajo: sé denso y factual.";
29
-
30
- function compactPrompt(transcript) {
31
- return (
32
- "Compactá estos turnos en un resumen estructurado de máximo 800 tokens, " +
33
- "preservando: decisiones tomadas, tareas asignadas, resultados de tools, y " +
34
- "datos acordados. Sin saludos ni meta-comentarios. Sólo los hechos.\n\n" +
35
- "---\n\n" +
36
- transcript
37
- );
38
- }
39
-
40
- export function resolveCompactModels(config = {}) {
41
- const mem = config.memory || {};
42
- // Primary: a light, local-endpoint model (Ollama, incl. *-cloud models served
43
- // via localhost). Fallback: whatever the user configured, else the APX
44
- // default super-agent model — never silently a paid service the user didn't
45
- // pick. A blank fallback resolves to super_agent.model at call time.
46
- return {
47
- primary: mem.compact_model || "ollama:gemma4:31b-cloud",
48
- fallback: mem.compact_fallback_model || config.super_agent?.model || "",
49
- };
50
- }
38
+ const DEFAULT_KEEP_FIRST = 2;
51
39
 
52
40
  // Read every record for a chat in the rolling window, oldest first.
53
41
  function readChatRecords({ channel, chat_id, max_age_hours, messagesDir }) {
@@ -84,30 +72,18 @@ function renderTurn(m) {
84
72
  return `[${who}]\n${String(m.body || "")}`;
85
73
  }
86
74
 
87
- async function summarize({ transcript, models, config }) {
88
- for (const modelId of [models.primary, models.fallback]) {
89
- if (!modelId) continue;
90
- try {
91
- const r = await callEngine({
92
- modelId,
93
- system: COMPACT_SYSTEM,
94
- messages: [{ role: "user", content: compactPrompt(transcript) }],
95
- config,
96
- maxTokens: COMPACT_MAX_TOKENS,
97
- temperature: 0.2,
98
- });
99
- const text = String(r.text || "").trim();
100
- if (text) return { text, model: modelId };
101
- } catch {
102
- /* try next model */
103
- }
75
+ function renderEvent(m, id) {
76
+ if (m.type === "tool") {
77
+ const name = m.meta?.tool_name || m.meta?.tool || "tool";
78
+ return `<EVENT id=${id} role=tool name=${name}>\n${String(m.body || "").slice(0, 600)}\n</EVENT>`;
104
79
  }
105
- return null;
80
+ const role = m.type === "user" ? "user" : "assistant";
81
+ return `<EVENT id=${id} role=${role}>\n${String(m.body || "")}\n</EVENT>`;
106
82
  }
107
83
 
108
84
  // Compact one channel chat if it's over threshold. Returns a small status obj.
109
- // opts: { channel, chat_id, config, log, maxTurns, keepRecent, max_age_hours,
110
- // messagesDir } (messagesDir overridable for tests)
85
+ // opts: { channel, chat_id, config, log, maxTurns, keepRecent, keepFirst,
86
+ // max_age_hours, messagesDir } (messagesDir overridable for tests)
111
87
  export async function compactChannelIfNeeded(opts = {}) {
112
88
  const channel = opts.channel || "telegram";
113
89
  const chat_id = opts.chat_id;
@@ -115,6 +91,7 @@ export async function compactChannelIfNeeded(opts = {}) {
115
91
  const log = typeof opts.log === "function" ? opts.log : () => {};
116
92
  const maxTurns = opts.maxTurns ?? config.memory?.compact_threshold ?? DEFAULT_MAX_TURNS;
117
93
  const keepRecent = opts.keepRecent ?? config.memory?.keep_recent ?? DEFAULT_KEEP_RECENT;
94
+ const keepFirst = opts.keepFirst ?? config.memory?.keep_first ?? DEFAULT_KEEP_FIRST;
118
95
  const max_age_hours = opts.max_age_hours ?? 24;
119
96
  const messagesDir = opts.messagesDir || GLOBAL_MESSAGES_DIR;
120
97
  if (!chat_id) return { skipped: "no chat_id" };
@@ -147,14 +124,44 @@ export async function compactChannelIfNeeded(opts = {}) {
147
124
  const compactedReal = toCompact.filter((m) => m.type === "user" || m.type === "agent").length;
148
125
  if (compactedReal === 0) return { skipped: "nothing to compact" };
149
126
 
150
- let transcript = toCompact.map(renderTurn).join("\n\n");
127
+ // keep_first (OpenHands): the first K events of a conversation are never
128
+ // condensed because they hold the original goal. Our JSONL model has a
129
+ // single covers_until_ts boundary and the reader drops EVERYTHING at or
130
+ // before it, so leaving those turns verbatim on disk reads would need a
131
+ // second boundary plus an intrusive reader change. We take the documented
132
+ // simplification instead: on the FIRST condensation (no previous compact —
133
+ // i.e. these really are the conversation's opening turns) the first
134
+ // `keepFirst` real turns are pulled out of the <EVENT> stream and quoted
135
+ // verbatim in the prompt with an instruction to preserve the original goal
136
+ // in USER_CONTEXT. On later condensations the goal already lives in the
137
+ // threaded previous summary, so keep_first no longer applies.
138
+ let openingTurns = [];
139
+ let eventRecords = toCompact;
140
+ if (!prevCompact && keepFirst > 0) {
141
+ openingTurns = toCompact
142
+ .filter((m) => m.type === "user" || m.type === "agent")
143
+ .slice(0, keepFirst);
144
+ const openingSet = new Set(openingTurns);
145
+ eventRecords = toCompact.filter((m) => !openingSet.has(m));
146
+ }
147
+
148
+ // Previous-summary threading: the last summary rides along as the first
149
+ // event so the new summary subsumes it (continuity across compactions).
150
+ const events = [];
151
151
  if (prevCompact && String(prevCompact.body || "").trim()) {
152
- transcript =
153
- `[RESUMEN PREVIO]\n${String(prevCompact.body).trim()}\n\n---\n\n` + transcript;
152
+ events.push(
153
+ `<EVENT id=0 role=summary>\n[PREVIOUS STATE SUMMARY]\n${String(prevCompact.body).trim()}\n</EVENT>`
154
+ );
154
155
  }
156
+ for (const m of eventRecords) events.push(renderEvent(m, events.length));
157
+
158
+ const prompt = buildCondenserPrompt({
159
+ eventsBlock: events.join("\n\n"),
160
+ openingBlock: openingTurns.map(renderTurn).join("\n\n"),
161
+ });
155
162
 
156
163
  const models = resolveCompactModels(config);
157
- const summary = await summarize({ transcript, models, config });
164
+ const summary = await summarizeStructured({ prompt, models, config });
158
165
  if (!summary) {
159
166
  log(`memory: compaction for ${channel}/${chat_id} skipped — no model available`);
160
167
  return { skipped: "no model" };
@@ -177,6 +184,8 @@ export async function compactChannelIfNeeded(opts = {}) {
177
184
  covers_until_ts: boundaryTs,
178
185
  compacted_turns: compactedReal,
179
186
  model: summary.model,
187
+ condenser: "v2",
188
+ ...(prevCompact ? { prev_compact_ts: prevCompact.ts } : {}),
180
189
  },
181
190
  });
182
191
  log(
@@ -0,0 +1,125 @@
1
+ // Structured-state summarizer — the ONE summarization service (condenser v2).
2
+ //
3
+ // Both compaction entry points share this brain so summaries are identical in
4
+ // quality no matter how they were triggered:
5
+ // - automatic: core/memory/compactor.js over the rolling cross-channel log
6
+ // - on demand: core/stores/conversations-compactor.js (apx session compact,
7
+ // the web "compact" button, POST …/compact)
8
+ // Different STORES, different triggers, ONE summarizer. Don't add a third.
9
+ //
10
+ // Mechanics ported from OpenHands' LLMSummarizingCondenser: a structured state
11
+ // (not a narrative recap) plus previous-summary threading for continuity.
12
+
13
+ import { callEngine } from "../engines/index.js";
14
+
15
+ // Structured state summaries carry several labelled sections, so they need more
16
+ // room than the old ~800-token narrative recap.
17
+ export const COMPACT_MAX_TOKENS = 1200;
18
+
19
+ export const CONDENSER_SYSTEM =
20
+ "You are maintaining a context-aware state summary for an interactive agent. " +
21
+ "Another model will read your summary to continue the work: be dense, factual, and structured.";
22
+
23
+ // The instructions live in the USER prompt (not the system prompt) so offline
24
+ // tests can capture the full instruction set through the echoing mock engine.
25
+ const CONDENSER_INSTRUCTIONS = `You will be given a list of events from an agent conversation as <EVENT> blocks. If the first event is a PREVIOUS STATE SUMMARY, your new summary must fully subsume it — carry forward all still-relevant state.
26
+
27
+ Maintain this structured state, one section per line group:
28
+
29
+ USER_CONTEXT: (essential user requirements, goals, and clarifications, in concise form)
30
+ TASK_TRACKING: (active tasks and their statuses; preserve exact task IDs)
31
+ COMPLETED: (tasks completed so far, with brief results)
32
+ PENDING: (tasks that still need to be done)
33
+ CURRENT_STATE: (current variables, data structures, or other relevant state)
34
+
35
+ For code-related tasks, also maintain:
36
+ CODE_STATE: (file paths, function signatures, data structures)
37
+ TESTS: (failing cases, error messages, outputs)
38
+ CHANGES: (code edits and their effects)
39
+ DEPS: (dependencies, imports, external calls)
40
+ VERSION_CONTROL_STATUS: (repository state, current branch, PR status, commits)
41
+
42
+ PRIORITIZE:
43
+ 1. Adapt the format to the actual task type — omit sections that do not apply.
44
+ 2. Capture key user requirements and goals.
45
+ 3. Distinguish completed work from pending work.
46
+ 4. Keep every section concise and relevant.
47
+
48
+ SKIP: greetings, meta-commentary, failed operations without semantic importance, repetitive details.
49
+
50
+ Output ONLY the summary sections (max ~900 tokens).`;
51
+
52
+ /**
53
+ * Resolve the compaction model chain. Primary: a light, local-endpoint model
54
+ * (Ollama, incl. *-cloud served via localhost). Fallback: whatever the user
55
+ * configured, else the APX default super-agent model — never silently a paid
56
+ * service the user didn't pick. A blank fallback resolves at call time.
57
+ */
58
+ export function resolveCompactModels(config = {}) {
59
+ const mem = config.memory || {};
60
+ return {
61
+ primary: mem.compact_model || "ollama:gemma4:31b-cloud",
62
+ fallback: mem.compact_fallback_model || config.super_agent?.model || "",
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Render a normalized turn list into `<EVENT>` blocks. Items:
68
+ * { role: "user"|"assistant"|"tool", content: string, name?: string }
69
+ * `prevSummary` (if any) rides along as EVENT id=0 role=summary so the new
70
+ * summary subsumes it (continuity across compactions).
71
+ */
72
+ export function renderEvents(items, { prevSummary = "" } = {}) {
73
+ const events = [];
74
+ if (prevSummary && String(prevSummary).trim()) {
75
+ events.push(
76
+ `<EVENT id=0 role=summary>\n[PREVIOUS STATE SUMMARY]\n${String(prevSummary).trim()}\n</EVENT>`
77
+ );
78
+ }
79
+ for (const it of items) {
80
+ const id = events.length;
81
+ if (it.role === "tool") {
82
+ const name = it.name || "tool";
83
+ events.push(`<EVENT id=${id} role=tool name=${name}>\n${String(it.content || "").slice(0, 600)}\n</EVENT>`);
84
+ } else {
85
+ const role = it.role === "user" ? "user" : "assistant";
86
+ events.push(`<EVENT id=${id} role=${role}>\n${String(it.content || "")}\n</EVENT>`);
87
+ }
88
+ }
89
+ return events.join("\n\n");
90
+ }
91
+
92
+ /** Assemble the full user prompt (opening verbatim block + events). */
93
+ export function buildCondenserPrompt({ eventsBlock, openingBlock = "" }) {
94
+ const opening = openingBlock
95
+ ? "The following opening turns of the conversation are quoted verbatim. They carry the ORIGINAL GOAL — preserve their intent (near-verbatim) under USER_CONTEXT:\n\n" +
96
+ `<CONVERSATION_OPENING>\n${openingBlock}\n</CONVERSATION_OPENING>\n\n`
97
+ : "";
98
+ return `${CONDENSER_INSTRUCTIONS}\n\n${opening}${eventsBlock}`;
99
+ }
100
+
101
+ /**
102
+ * Run the summarizer over a prompt, walking the model chain. Returns
103
+ * { text, model } or null when no model produced text (caller decides what a
104
+ * null means — skip compaction, keep raw history, etc.).
105
+ */
106
+ export async function summarizeStructured({ prompt, models, config, maxTokens = COMPACT_MAX_TOKENS }) {
107
+ for (const modelId of [models.primary, models.fallback]) {
108
+ if (!modelId) continue;
109
+ try {
110
+ const r = await callEngine({
111
+ modelId,
112
+ system: CONDENSER_SYSTEM,
113
+ messages: [{ role: "user", content: prompt }],
114
+ config,
115
+ maxTokens,
116
+ temperature: 0.2,
117
+ });
118
+ const text = String(r.text || "").trim();
119
+ if (text) return { text, model: modelId };
120
+ } catch {
121
+ /* try next model */
122
+ }
123
+ }
124
+ return null;
125
+ }
@@ -25,30 +25,22 @@
25
25
  import fs from "node:fs";
26
26
  import path from "node:path";
27
27
  import { parseConversation } from "./conversations.js";
28
- import { callEngine } from "#core/engines/index.js";
28
+ import {
29
+ renderEvents,
30
+ buildCondenserPrompt,
31
+ summarizeStructured,
32
+ } from "#core/memory/summarizer.js";
29
33
 
30
34
  const KEEP_LAST = 6;
31
35
 
32
36
  const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
33
37
 
34
- const COMPACT_SYSTEM =
35
- "You summarize conversations for AI agent context continuity. " +
36
- "Be dense and factual another AI will read this to continue the work.";
37
-
38
- const COMPACT_PROMPT = `Summarize this conversation for future context.
39
-
40
- Cover:
41
- - Main task or goal being worked on
42
- - Key decisions made and why
43
- - Files, code, commands modified (exact paths where relevant)
44
- - Current state: what's done, what's pending or unresolved
45
- - Errors encountered and how they were resolved
46
-
47
- Style: dense and factual. No pleasantries. No meta-commentary. Just the facts.
48
-
49
- ---
50
-
51
- `;
38
+ // Map a parsed conversation role to the summarizer's normalized event role.
39
+ function toEventRole(role) {
40
+ if (role === "user") return "user";
41
+ if (role === "tool") return "tool";
42
+ return "assistant"; // assistant / system assistant side
43
+ }
52
44
 
53
45
  // Resolve the most-recent conversation file for an agent, or the one explicitly
54
46
  // named. Returns the full filepath.
@@ -90,19 +82,21 @@ export async function compactConversation({
90
82
  const realTurns = turns.filter((t) => t.role !== "compact");
91
83
  if (realTurns.length === 0) throw new Error("nothing to compact — no user/assistant turns");
92
84
 
93
- // Build a readable transcript for the model.
94
- const transcript = realTurns
95
- .map((t) => `[${t.role.toUpperCase()}]\n${t.content}`)
96
- .join("\n\n---\n\n");
97
-
98
- const result = await callEngine({
99
- modelId,
100
- system: COMPACT_SYSTEM,
101
- messages: [{ role: "user", content: COMPACT_PROMPT + transcript }],
85
+ // Same summarizer service as the automatic condenser (structured state),
86
+ // just a different store/entry point. The whole conversation is condensed
87
+ // (no previous-summary threading here — a fresh compact per call).
88
+ const eventsBlock = renderEvents(
89
+ realTurns.map((t) => ({ role: toEventRole(t.role), content: t.content }))
90
+ );
91
+ const prompt = buildCondenserPrompt({ eventsBlock });
92
+ const out = await summarizeStructured({
93
+ prompt,
94
+ models: { primary: modelId, fallback: config?.super_agent?.model || "" },
102
95
  config,
103
96
  });
97
+ if (!out) throw new Error("compaction failed — no model produced a summary");
104
98
 
105
- const summary = result.text.trim();
99
+ const summary = out.text;
106
100
  const ts = nowIso();
107
101
  const turnCount = realTurns.length;
108
102
 
@@ -133,8 +127,7 @@ export async function compactConversation({
133
127
  filename: path.basename(filepath),
134
128
  compacted_turns: turnCount,
135
129
  kept_turns: recentTurns.length,
136
- model: modelId,
130
+ model: out.model,
137
131
  summary,
138
- usage: result.usage,
139
132
  };
140
133
  }
@@ -14,6 +14,7 @@ import {
14
14
  isSecretMarker,
15
15
  mergeRedactedChannels,
16
16
  } from "#core/config/redact.js";
17
+ import { collectSecretValues, registerSecretValues } from "#core/config/secret-values.js";
17
18
 
18
19
  export function register(app, { config, scheduler, plugins }) {
19
20
  app.get("/admin/config", (_req, res) => {
@@ -57,6 +58,10 @@ export function register(app, { config, scheduler, plugins }) {
57
58
  const fresh = readConfig();
58
59
  for (const key of Object.keys(config)) delete config[key];
59
60
  Object.assign(config, fresh);
61
+ // Keep the log-masking registry current: any secret just added via PATCH
62
+ // must be masked from this point on (registry is additive — removed
63
+ // secrets stay masked, which is the safe direction).
64
+ registerSecretValues(collectSecretValues(fresh));
60
65
  if (scheduler) scheduler.globalConfig = config;
61
66
  if (plugins) plugins.config = config;
62
67
  res.json({ ok: true, config: redact(fresh) });