@agentprojectcontext/apx 1.66.0 → 1.68.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.
- package/package.json +3 -2
- package/skills/apx/SKILL.md +3 -0
- package/src/core/agent/index.js +2 -0
- package/src/core/agent/judge.js +174 -0
- package/src/core/agent/model-router.js +107 -5
- package/src/core/agent/prompts/modes/code-build.md +1 -1
- package/src/core/agent/run-agent.js +149 -12
- package/src/core/agent/security.js +97 -0
- package/src/core/agent/stuck-detector.js +89 -0
- package/src/core/agent/super-agent.js +58 -17
- package/src/core/agent/tools/handlers/run-subagent.js +117 -0
- package/src/core/agent/tools/helpers.js +11 -1
- package/src/core/agent/tools/names.js +2 -0
- package/src/core/agent/tools/registry.js +10 -0
- package/src/core/artifacts/preview.js +392 -0
- package/src/core/artifacts/tunnel.js +169 -0
- package/src/core/config/index.js +61 -0
- package/src/core/config/redact.js +44 -0
- package/src/core/config/secret-values.js +132 -0
- package/src/core/engines/mock.js +15 -1
- package/src/core/logging.js +10 -3
- package/src/core/memory/compactor.js +65 -56
- package/src/core/memory/summarizer.js +125 -0
- package/src/core/stores/conversations-compactor.js +24 -31
- package/src/host/daemon/api/admin-config.js +5 -0
- package/src/host/daemon/api/artifact-preview.js +82 -0
- package/src/host/daemon/api/config.js +17 -5
- package/src/host/daemon/api/sessions.js +9 -0
- package/src/host/daemon/api/web.js +1 -1
- package/src/host/daemon/api.js +2 -0
- package/src/host/daemon/index.js +16 -1
- package/src/interfaces/acp/index.js +363 -0
- package/src/interfaces/acp/jsonrpc.js +180 -0
- package/src/interfaces/acp/session.js +205 -0
- package/src/interfaces/cli/commands/acp.js +10 -0
- package/src/interfaces/cli/commands/artifact.js +115 -0
- package/src/interfaces/cli/index.js +74 -0
- package/src/interfaces/web/dist/assets/index-D4BmWoDM.css +1 -0
- package/src/interfaces/web/dist/assets/index-vwd6yQVw.js +803 -0
- package/src/interfaces/web/dist/assets/index-vwd6yQVw.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +9 -9
- package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
- package/src/interfaces/web/src/components/config/ConfigTabsEditor.tsx +46 -31
- package/src/interfaces/web/src/components/config/project-config-sections.ts +9 -11
- package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +162 -0
- package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
- package/src/interfaces/web/src/i18n/en.ts +53 -0
- package/src/interfaces/web/src/i18n/es.ts +53 -0
- package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
- package/src/interfaces/web/src/lib/api/sessions.ts +2 -1
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +50 -60
- package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
- package/src/interfaces/web/src/screens/base/SessionsTab.tsx +11 -5
- package/src/interfaces/web/src/screens/project/ConfigTab.tsx +110 -25
- package/src/interfaces/web/src/screens/project/MemoriesTab.tsx +7 -128
- package/src/interfaces/web/src/screens/project/Overview.tsx +3 -2
- package/src/interfaces/web/src/types/daemon.ts +16 -0
- package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
- package/src/interfaces/web/dist/assets/index-YmMRG--4.js +0 -778
- 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
|
+
}
|
package/src/core/engines/mock.js
CHANGED
|
@@ -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
|
}
|
package/src/core/logging.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
//
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 =
|
|
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:
|
|
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) });
|