flint-agent 1.14.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/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- package/system.md +63 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Lock utility for serializing command execution (e.g. /paste, /auto, /new)
|
|
2
|
+
// Message processing goes through bus drain loop (src/bus/drain-loop.js)
|
|
3
|
+
import { app } from "./app-state.js";
|
|
4
|
+
|
|
5
|
+
export function withLock(fn) {
|
|
6
|
+
const prev = app.lockChain;
|
|
7
|
+
let unlock;
|
|
8
|
+
app.lockChain = new Promise((r) => (unlock = r));
|
|
9
|
+
return prev.then(() => {
|
|
10
|
+
if (app.queueAborted) return;
|
|
11
|
+
return fn();
|
|
12
|
+
}).finally(unlock);
|
|
13
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// What the operator sees must be what is sent.
|
|
2
|
+
//
|
|
3
|
+
// A pasted multi-line message looked cut and overwritten in the input box,
|
|
4
|
+
// though the agent received it whole. Nothing was wrong with what was sent,
|
|
5
|
+
// which is why it never showed up in a message log — the logs looked perfect.
|
|
6
|
+
//
|
|
7
|
+
// The cause is a character the terminal and the text disagree about. A paste
|
|
8
|
+
// on Windows (and from some terminals) arrives with the line breaks as CRLF,
|
|
9
|
+
// or as a bare CR. A terminal draws a CR as "back to column 0", so the next
|
|
10
|
+
// line is written *over* the one before it:
|
|
11
|
+
//
|
|
12
|
+
// paste: "line one\r\nline two\r\nline three" 30 chars, 2 CRs
|
|
13
|
+
// drawn: "\nline three" 1 line, the rest gone
|
|
14
|
+
//
|
|
15
|
+
// So the operator sees one line and sends three. They fix it by retyping the
|
|
16
|
+
// parts that appear to have vanished, which is the worst outcome: the
|
|
17
|
+
// correction is guesswork, and what finally reaches the agent is the
|
|
18
|
+
// retyped version, not what they pasted.
|
|
19
|
+
//
|
|
20
|
+
// The fix is here rather than in the component, because the disagreement is
|
|
21
|
+
// about the text, not about the drawing: normalize the line endings once, at
|
|
22
|
+
// the boundary, and everything downstream — the drawing, the cursor, the
|
|
23
|
+
// submitted message — then works on the same string. Deliberately narrow: no
|
|
24
|
+
// trimming, no rewrapping, no reflowing. A pasted diff, a stack trace or a
|
|
25
|
+
// JSON blob must arrive exactly as it was pasted, or the agent is reasoning
|
|
26
|
+
// about a document nobody sent.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Does this text contain a CR that is not part of a CRLF pair?
|
|
30
|
+
*
|
|
31
|
+
* \r\n is an ordinary line ending and is left alone. A bare \r is not — it is
|
|
32
|
+
* a control character the terminal acts on, which is the whole bug.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} text
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function hasBareCR(text) {
|
|
38
|
+
if (!text) return false;
|
|
39
|
+
return /\r(?!\n)/.test(String(text));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The line endings a terminal cannot draw, turned into ones it can.
|
|
44
|
+
*
|
|
45
|
+
* CRLF first, then any remaining bare CR. Doing them in that order is what
|
|
46
|
+
* makes the result idempotent: after one pass there is no \r left at all, so
|
|
47
|
+
* a second pass is a no-op. That matters because the input normalizes on
|
|
48
|
+
* every keystroke.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} text
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeInputText(text) {
|
|
54
|
+
if (text === null || text === undefined) return "";
|
|
55
|
+
const s = String(text);
|
|
56
|
+
if (!s) return "";
|
|
57
|
+
if (!s.includes("\r")) return s;
|
|
58
|
+
return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The text as the operator will see it in the input box.
|
|
63
|
+
*
|
|
64
|
+
* What the terminal would draw, given text a terminal can draw. Exported
|
|
65
|
+
* because "what does the operator see" is the question this whole module
|
|
66
|
+
* exists to answer, and it should be answerable in a test without rendering
|
|
67
|
+
* a terminal.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} text
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
export function displayTextFor(text) {
|
|
73
|
+
return expandTabs(normalizeInputText(text));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* How many lines the operator can actually see, as opposed to how many the
|
|
78
|
+
* paste contained.
|
|
79
|
+
*
|
|
80
|
+
* Before the fix these two numbers differed, and the difference was the bug.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* @returns {number}
|
|
84
|
+
*/
|
|
85
|
+
export function visibleLineCount(text) {
|
|
86
|
+
const shown = displayTextFor(text);
|
|
87
|
+
return shown ? shown.split("\n").length : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The tab stop the input renders at. A tab in the input is a jump to the next
|
|
91
|
+
// stop, so the operator sees a gap while the agent receives a 0x09; expanding
|
|
92
|
+
// it shows the character and the gap as the same thing.
|
|
93
|
+
const TAB_WIDTH = 4;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Tabs as spaces, so what is drawn is what was typed.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} text
|
|
99
|
+
* @param {number} [tabWidth]
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function expandTabs(text, tabWidth = TAB_WIDTH) {
|
|
103
|
+
if (!text) return "";
|
|
104
|
+
const s = String(text);
|
|
105
|
+
if (!s.includes("\t")) return s;
|
|
106
|
+
let out = "";
|
|
107
|
+
for (const line of s.split("\n")) {
|
|
108
|
+
let col = 0;
|
|
109
|
+
for (const ch of line) {
|
|
110
|
+
if (ch === "\t") {
|
|
111
|
+
const pad = tabWidth - (col % tabWidth);
|
|
112
|
+
out += " ".repeat(pad);
|
|
113
|
+
col += pad;
|
|
114
|
+
} else {
|
|
115
|
+
out += ch;
|
|
116
|
+
col += 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
out += "\n";
|
|
120
|
+
}
|
|
121
|
+
// split/join added a trailing newline that the input did not have.
|
|
122
|
+
return out.replace(/\n$/, "");
|
|
123
|
+
}
|
package/src/launcher.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const projectRoot = path.resolve(__dirname, "..");
|
|
7
|
+
const userArgs = process.argv.slice(2);
|
|
8
|
+
|
|
9
|
+
// --version answers and exits here too, not only in bin/flint.js: a host
|
|
10
|
+
// that starts Flint with `npm start -- --version` or `node src/launcher.js
|
|
11
|
+
// --version` got a console instead of a version (node integration test,
|
|
12
|
+
// 2026-10-02).
|
|
13
|
+
if (userArgs.includes("--version") || userArgs.includes("-v")) {
|
|
14
|
+
const { readFileSync } = await import("node:fs");
|
|
15
|
+
console.log(`flint ${JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")).version}`);
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const hasSessionArg = userArgs.some(a => a === "--last" || a === "--session" || a === "--list" || a === "--new");
|
|
20
|
+
|
|
21
|
+
const RESTART_CODE = 42;
|
|
22
|
+
|
|
23
|
+
// The session a restarting child asked to continue (src/restart.js). A
|
|
24
|
+
// restart names its own session, so the operator's --new/--last/--session
|
|
25
|
+
// from the first start are left out of the args then.
|
|
26
|
+
let resumeSessionId = null;
|
|
27
|
+
function withoutSessionArgs(args) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
if (args[i] === "--new" || args[i] === "--last") continue;
|
|
31
|
+
if (args[i] === "--session") { i++; continue; }
|
|
32
|
+
out.push(args[i]);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The stdio mode (src/stdio) is driven by a program, not a person: no splash,
|
|
38
|
+
// no spinner, and the agent runs in the folder it was started from, which is
|
|
39
|
+
// the agent's own folder (its CLAUDE.md, .mcp.json and files).
|
|
40
|
+
const stdioMode = userArgs.includes("--stdio") || userArgs.some((a, i) =>
|
|
41
|
+
(a === "--input-format" || a === "--output-format") && userArgs[i + 1] === "stream-json");
|
|
42
|
+
|
|
43
|
+
// Clear screen + show the FLiNT mark (src/ui/splash.js) with a loading spinner
|
|
44
|
+
// on its second row. Raw escapes, no chalk: the launcher loads nothing it can
|
|
45
|
+
// avoid. Titanium is 256-colour 250, the spark yellow 220, grey 244.
|
|
46
|
+
const _mark0 = " \x1b[38;5;250m▀▀▀ █ \x1b[38;5;220m▀\x1b[38;5;250m █▄ █ ▀█▀\x1b[0m";
|
|
47
|
+
const _mark1 = " \x1b[38;5;250m█▀▀ █▄▄ █ █ ▀█ █\x1b[0m";
|
|
48
|
+
const _frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
49
|
+
if (!stdioMode) process.stderr.write(`\x1b[2J\x1b[H\n${_mark0}\n${_mark1} \x1b[38;5;244m${_frames[0]} loading...\x1b[0m`);
|
|
50
|
+
let _fi = 0;
|
|
51
|
+
let _released = false;
|
|
52
|
+
const _spinner = setInterval(() => {
|
|
53
|
+
_fi = (_fi + 1) % _frames.length;
|
|
54
|
+
if (_released) return;
|
|
55
|
+
process.stderr.write(`\r${_mark1} \x1b[38;5;244m${_frames[_fi]} loading...\x1b[0m`);
|
|
56
|
+
}, 120);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hand the terminal over to the child.
|
|
60
|
+
*
|
|
61
|
+
* The spinner repaints with a bare `\r`, which the terminal reads as "back to
|
|
62
|
+
* column 0 of whatever line is current now". Once the child starts drawing,
|
|
63
|
+
* that is no longer the spinner's line: every tick reprints the FLINT AGENT
|
|
64
|
+
* banner on top of the child's own banner, and the startup banner with the
|
|
65
|
+
* first commands underneath it appears several times over.
|
|
66
|
+
*
|
|
67
|
+
* This used to be a fixed 500 ms after spawn, on the assumption that the child
|
|
68
|
+
* would be rendering by then. How long that takes depends on module load and on
|
|
69
|
+
* the machine, so a slow start got the spinner writing into a live UI, which is
|
|
70
|
+
* the reported glitch.
|
|
71
|
+
*
|
|
72
|
+
* The child is spawned with `stdio: "inherit"`, so it has no pipe handles to
|
|
73
|
+
* listen to: child.stdout and child.stderr are both null. An earlier attempt
|
|
74
|
+
* attached "release on first output" to those and did nothing at all, which
|
|
75
|
+
* would have left the spinner running forever while appearing to work. The
|
|
76
|
+
* streams stay inherited on purpose — piping them would cost Ink the TTY, and
|
|
77
|
+
* raw-mode key handling with it.
|
|
78
|
+
*
|
|
79
|
+
* So the child says when it has drawn, over an IPC channel that costs nothing
|
|
80
|
+
* and is invisible to the terminal. The interval is kept as a ceiling rather
|
|
81
|
+
* than removed: a child that dies without saying anything must not hold the
|
|
82
|
+
* event loop open, and `_released` makes the remaining ticks no-ops.
|
|
83
|
+
*/
|
|
84
|
+
function releaseTerminal() {
|
|
85
|
+
_released = true;
|
|
86
|
+
clearInterval(_spinner);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function start(extraArgs = []) {
|
|
90
|
+
const defaultArgs = (!hasSessionArg && extraArgs.length === 0 && !stdioMode) ? ["--new"] : [];
|
|
91
|
+
const baseArgs = extraArgs.length ? withoutSessionArgs(userArgs) : userArgs;
|
|
92
|
+
const args = [path.join(projectRoot, "src", "index.js"), ...baseArgs, ...defaultArgs, ...extraArgs];
|
|
93
|
+
const child = spawn(process.execPath, args, {
|
|
94
|
+
// The three standard streams stay inherited: piping them would cost Ink the
|
|
95
|
+
// TTY, and with it raw-mode key handling. The fourth slot is an IPC
|
|
96
|
+
// channel, which does not touch the terminal, and is how the child says
|
|
97
|
+
// "I have drawn" so the spinner stops repainting over it.
|
|
98
|
+
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
|
99
|
+
cwd: stdioMode ? process.cwd() : projectRoot,
|
|
100
|
+
});
|
|
101
|
+
if (stdioMode) releaseTerminal();
|
|
102
|
+
|
|
103
|
+
child.on("message", (msg) => {
|
|
104
|
+
if (msg && msg.type === "flint:ready") releaseTerminal();
|
|
105
|
+
if (msg && msg.type === "flint:restart" && typeof msg.sessionId === "string") resumeSessionId = msg.sessionId;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// `exit` always arrives, and it must stop the interval even if the child
|
|
109
|
+
// never said anything — otherwise it holds the event loop open.
|
|
110
|
+
child.on("exit", (code) => {
|
|
111
|
+
releaseTerminal();
|
|
112
|
+
if (code === RESTART_CODE && !stdioMode) {
|
|
113
|
+
// The same session when the child said which; a new one otherwise.
|
|
114
|
+
const id = resumeSessionId;
|
|
115
|
+
resumeSessionId = null;
|
|
116
|
+
start(id ? ["--session", id] : ["--new"]);
|
|
117
|
+
} else {
|
|
118
|
+
process.exit(code ?? 0);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
child.on("error", releaseTerminal);
|
|
122
|
+
|
|
123
|
+
// Forward Ctrl+C to child gracefully
|
|
124
|
+
process.on("SIGINT", () => {
|
|
125
|
+
child.kill("SIGINT");
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
start();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export function logApiCall(sessionId, callNum, messages, tools, reply, usage) {
|
|
6
|
+
if (!sessionId) return;
|
|
7
|
+
const file = path.join(config.sessionsDir, `${sessionId}.api.log`);
|
|
8
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
if (messages) {
|
|
12
|
+
const sep = "=".repeat(60);
|
|
13
|
+
let totalChars = 0;
|
|
14
|
+
const lines = [];
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < messages.length; i++) {
|
|
17
|
+
const m = messages[i];
|
|
18
|
+
const contentLen = (m.content || "").length;
|
|
19
|
+
totalChars += contentLen;
|
|
20
|
+
|
|
21
|
+
let desc = ` [${i}] ${m.role}: ${contentLen} chars`;
|
|
22
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
23
|
+
const toolNames = m.tool_calls.map((tc) => tc.function?.name || "?").join(", ");
|
|
24
|
+
desc += ` + tools=[${toolNames}]`;
|
|
25
|
+
}
|
|
26
|
+
if (m.role === "tool") {
|
|
27
|
+
const name = m._toolName || "?";
|
|
28
|
+
desc += ` (${name})`;
|
|
29
|
+
if (m._compressed) desc += ` [${m._compressed}]`;
|
|
30
|
+
}
|
|
31
|
+
lines.push(desc);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const toolCount = tools?.length || 0;
|
|
35
|
+
const header = [
|
|
36
|
+
`${sep}`,
|
|
37
|
+
`[${ts}] API Call #${callNum}`,
|
|
38
|
+
`${sep}`,
|
|
39
|
+
`Messages: ${messages.length} (${totalChars} chars total content)`,
|
|
40
|
+
`Tools: ${toolCount} definitions`,
|
|
41
|
+
].join("\n");
|
|
42
|
+
|
|
43
|
+
appendFileSync(file, `${header}\n${lines.join("\n")}\n`);
|
|
44
|
+
|
|
45
|
+
// Full message dump: write complete content for prompt analysis
|
|
46
|
+
const rawFile = path.join(config.sessionsDir, `${sessionId}.messages.jsonl`);
|
|
47
|
+
try {
|
|
48
|
+
const entry = {
|
|
49
|
+
ts, callNum,
|
|
50
|
+
messages: messages.map(m => ({
|
|
51
|
+
role: m.role,
|
|
52
|
+
content: m.content || "",
|
|
53
|
+
tool_calls: m.tool_calls,
|
|
54
|
+
tool_call_id: m.tool_call_id,
|
|
55
|
+
})),
|
|
56
|
+
toolCount,
|
|
57
|
+
};
|
|
58
|
+
appendFileSync(rawFile, JSON.stringify(entry) + "\n");
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (reply) {
|
|
63
|
+
const contentLen = (reply.content || "").length;
|
|
64
|
+
const toolCalls = reply.tool_calls || [];
|
|
65
|
+
let toolDesc = "none";
|
|
66
|
+
if (toolCalls.length) {
|
|
67
|
+
toolDesc = toolCalls
|
|
68
|
+
.map((tc) => {
|
|
69
|
+
const name = tc.function?.name || "?";
|
|
70
|
+
let argsShort = "";
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(tc.function?.arguments || "{}");
|
|
73
|
+
argsShort = Object.entries(parsed)
|
|
74
|
+
.map(([k, v]) => {
|
|
75
|
+
const s = typeof v === "string" && v.length > 60 ? v.slice(0, 60) + "..." : String(v);
|
|
76
|
+
return `${k}="${s}"`;
|
|
77
|
+
})
|
|
78
|
+
.join(", ");
|
|
79
|
+
} catch {}
|
|
80
|
+
return `${name}(${argsShort})`;
|
|
81
|
+
})
|
|
82
|
+
.join("; ");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const usageStr = usage
|
|
86
|
+
? `prompt=${usage.prompt_tokens || 0} completion=${usage.completion_tokens || 0} total=${(usage.prompt_tokens || 0) + (usage.completion_tokens || 0)}`
|
|
87
|
+
: "n/a";
|
|
88
|
+
|
|
89
|
+
appendFileSync(
|
|
90
|
+
file,
|
|
91
|
+
`Response: ${contentLen} chars, tool_calls: ${toolDesc}\nUsage: ${usageStr}\n\n`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The chat log must never stop writing, however long the session runs.
|
|
2
|
+
//
|
|
3
|
+
// Item 9: "chat.log silently stops after 1000 screen lines".
|
|
4
|
+
//
|
|
5
|
+
// The cause was a position tracked by array *length*. `addLine` caps the
|
|
6
|
+
// on-screen `lines` array (src/store/ui-slice.js) at maxDisplayLines — 1000 by
|
|
7
|
+
// default — by dropping the oldest entries. So the array stops growing once it
|
|
8
|
+
// is full, and the subscriber decided what was new with:
|
|
9
|
+
//
|
|
10
|
+
// if (lines.length > lastLineCount) {
|
|
11
|
+
// for (let i = lastLineCount; i < lines.length; i++) logChatLine(...);
|
|
12
|
+
// lastLineCount = lines.length;
|
|
13
|
+
// }
|
|
14
|
+
//
|
|
15
|
+
// At 1000 entries that condition is false forever. The log stops mid-session
|
|
16
|
+
// and stays stopped: no error, no message, and the file on disk simply ends
|
|
17
|
+
// where the screen filled up. Everything after that is lost — including the
|
|
18
|
+
// lines you would most want, the turn where it went wrong.
|
|
19
|
+
//
|
|
20
|
+
// The store already stamps every line with a monotonic `id` (`nextLineId`).
|
|
21
|
+
// That is the position which does not stop moving, and it is what this uses.
|
|
22
|
+
//
|
|
23
|
+
// The limit that cannot be engineered away: the log cannot be rebuilt from the
|
|
24
|
+
// array, because the array no longer holds the lines. A follower can only
|
|
25
|
+
// record what it saw as it went past. So this reports a gap when it starts
|
|
26
|
+
// late rather than pretending to be complete — a follower that silently skips
|
|
27
|
+
// what it missed is how this hid for so long.
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Follow the on-screen lines and write each new one to the chat log exactly
|
|
31
|
+
* once, for as long as the session runs.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} deps
|
|
34
|
+
* @param {() => object} deps.getState
|
|
35
|
+
* @param {(fn: Function) => Function} deps.subscribe
|
|
36
|
+
* @param {(sessionId: string, text: string) => void} deps.log
|
|
37
|
+
* @returns {{start: Function, stop: Function, written: number}}
|
|
38
|
+
*/
|
|
39
|
+
export function createChatLogFollower({ getState, subscribe, log }) {
|
|
40
|
+
// The id of the last line written. Not the array length: the length stops
|
|
41
|
+
// moving at the cap, which is the entire bug.
|
|
42
|
+
let lastId = 0;
|
|
43
|
+
// clearScreen() resets nextLineId to 1, so ids restart. A single watermark
|
|
44
|
+
// would then read every later id as "already seen" and the log would die one
|
|
45
|
+
// screen in — the same silent failure, relocated. The generation counter is
|
|
46
|
+
// what makes the watermark per-page.
|
|
47
|
+
let generation = -1;
|
|
48
|
+
let unsubscribe = null;
|
|
49
|
+
let written = 0;
|
|
50
|
+
|
|
51
|
+
function currentGeneration(state) {
|
|
52
|
+
return state.clearCounter ?? 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function drain(state) {
|
|
56
|
+
const gen = currentGeneration(state);
|
|
57
|
+
if (gen !== generation) {
|
|
58
|
+
// A new page. Nothing is pending, and the id sequence restarts.
|
|
59
|
+
generation = gen;
|
|
60
|
+
lastId = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Log only what is newer than the watermark. Ids are not contiguous once
|
|
64
|
+
// the array has slid, so this is a comparison per line rather than a range.
|
|
65
|
+
for (const line of state.lines) {
|
|
66
|
+
if (line && typeof line.id === "number" && line.id > lastId) {
|
|
67
|
+
// A replayed line is already in this log (ui/replay.js).
|
|
68
|
+
if (!line.replay) {
|
|
69
|
+
log(state.sessionId ?? "", line.text);
|
|
70
|
+
written++;
|
|
71
|
+
}
|
|
72
|
+
lastId = line.id;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
/**
|
|
79
|
+
* Attach, and report anything that was already on screen.
|
|
80
|
+
*
|
|
81
|
+
* @returns {{skipped: number}} lines that existed before this ran
|
|
82
|
+
*/
|
|
83
|
+
start() {
|
|
84
|
+
const state = getState();
|
|
85
|
+
generation = currentGeneration(state);
|
|
86
|
+
lastId = 0;
|
|
87
|
+
|
|
88
|
+
// Anything already on screen predates this follower. It cannot be
|
|
89
|
+
// logged honestly — it is not "skipped" by choice, it was never seen —
|
|
90
|
+
// so the count is returned rather than swallowed.
|
|
91
|
+
let skipped = 0;
|
|
92
|
+
for (const line of state.lines) {
|
|
93
|
+
if (line && typeof line.id === "number" && line.id > lastId) {
|
|
94
|
+
log(state.sessionId ?? "", line.text);
|
|
95
|
+
lastId = line.id;
|
|
96
|
+
written++;
|
|
97
|
+
skipped++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
unsubscribe = subscribe(() => drain(getState()));
|
|
102
|
+
return { skipped };
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
stop() {
|
|
106
|
+
if (unsubscribe) unsubscribe();
|
|
107
|
+
unsubscribe = null;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/** How many lines this follower has written. For tests and diagnostics. */
|
|
111
|
+
get written() { return written; },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
|
|
5
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
6
|
+
function stripAnsi(s) { return s.replace(ANSI_RE, ""); }
|
|
7
|
+
|
|
8
|
+
export function logChatLine(sessionId, text) {
|
|
9
|
+
if (!sessionId) return;
|
|
10
|
+
const file = path.join(config.sessionsDir, `${sessionId}.chat.log`);
|
|
11
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
12
|
+
try {
|
|
13
|
+
appendFileSync(file, `[${ts}] ${stripAnsi(text)}\n`);
|
|
14
|
+
} catch {}
|
|
15
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log collector — background cleanup of old session files and stale registry entries.
|
|
3
|
+
*
|
|
4
|
+
* Runs once at startup (after a short delay) then periodically (default: every hour).
|
|
5
|
+
* Configurable via env:
|
|
6
|
+
* AGENT_LOG_RETENTION_DAYS — delete logs older than N days (default: 7)
|
|
7
|
+
* AGENT_SESSION_RETENTION_DAYS — delete session JSONs older than N days (default: 30)
|
|
8
|
+
* AGENT_CLEANUP_INTERVAL_MIN — interval in minutes (default: 60)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdirSync, statSync, unlinkSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { config } from "../config.js";
|
|
14
|
+
import { createLogger } from "./logger.js";
|
|
15
|
+
import { cleanup as busCleanupFn } from "../bus/index.js";
|
|
16
|
+
|
|
17
|
+
const log = createLogger("collector");
|
|
18
|
+
|
|
19
|
+
const LOG_RETENTION_DAYS = parseInt(process.env.AGENT_LOG_RETENTION_DAYS || "7", 10);
|
|
20
|
+
const SESSION_RETENTION_DAYS = parseInt(process.env.AGENT_SESSION_RETENTION_DAYS || "30", 10);
|
|
21
|
+
const CLEANUP_INTERVAL_MIN = parseInt(process.env.AGENT_CLEANUP_INTERVAL_MIN || "60", 10);
|
|
22
|
+
|
|
23
|
+
// File extensions considered "log files" (shorter retention)
|
|
24
|
+
const LOG_EXTENSIONS = new Set([".log"]);
|
|
25
|
+
// Session data files (longer retention)
|
|
26
|
+
const SESSION_EXTENSIONS = new Set([".json", ".hmac"]);
|
|
27
|
+
|
|
28
|
+
function isExpired(filePath, retentionDays) {
|
|
29
|
+
try {
|
|
30
|
+
const stat = statSync(filePath);
|
|
31
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
32
|
+
return ageMs > retentionDays * 86400000;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getExtGroup(filename) {
|
|
39
|
+
// e.g. "2026-03-13T03-56-39.api.log" → ".log"
|
|
40
|
+
// e.g. "2026-03-13T03-56-39.json" → ".json"
|
|
41
|
+
const ext = path.extname(filename); // last extension
|
|
42
|
+
if (LOG_EXTENSIONS.has(ext)) return "log";
|
|
43
|
+
if (SESSION_EXTENSIONS.has(ext)) return "session";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cleanDirectory(dir) {
|
|
48
|
+
if (!existsSync(dir)) return { logsRemoved: 0, sessionsRemoved: 0 };
|
|
49
|
+
|
|
50
|
+
let logsRemoved = 0;
|
|
51
|
+
let sessionsRemoved = 0;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const files = readdirSync(dir);
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
// Skip special files
|
|
57
|
+
if (file.startsWith(".")) continue;
|
|
58
|
+
|
|
59
|
+
const group = getExtGroup(file);
|
|
60
|
+
if (!group) continue;
|
|
61
|
+
|
|
62
|
+
const filePath = path.join(dir, file);
|
|
63
|
+
const retention = group === "log" ? LOG_RETENTION_DAYS : SESSION_RETENTION_DAYS;
|
|
64
|
+
|
|
65
|
+
if (isExpired(filePath, retention)) {
|
|
66
|
+
try {
|
|
67
|
+
unlinkSync(filePath);
|
|
68
|
+
if (group === "log") logsRemoved++;
|
|
69
|
+
else sessionsRemoved++;
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
log.warn("Failed to read directory for cleanup", { dir, error: err.message });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { logsRemoved, sessionsRemoved };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function cleanRegistry() {
|
|
81
|
+
const { homedir } = await import("node:os");
|
|
82
|
+
const { join } = await import("node:path");
|
|
83
|
+
const registryFile = join(homedir(), ".flint", "agents.json");
|
|
84
|
+
|
|
85
|
+
if (!existsSync(registryFile)) return 0;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const data = JSON.parse(readFileSync(registryFile, "utf-8"));
|
|
89
|
+
if (!Array.isArray(data)) return 0;
|
|
90
|
+
|
|
91
|
+
const before = data.length;
|
|
92
|
+
const alive = data.filter((entry) => {
|
|
93
|
+
try {
|
|
94
|
+
// Check if process is still running
|
|
95
|
+
process.kill(entry.pid, 0);
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (alive.length < before) {
|
|
103
|
+
writeFileSync(registryFile, JSON.stringify(alive, null, 2));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return before - alive.length;
|
|
107
|
+
} catch {
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function runCollector() {
|
|
113
|
+
log.debug("Log collector running");
|
|
114
|
+
|
|
115
|
+
// Clean main sessions directory
|
|
116
|
+
const main = cleanDirectory(config.sessionsDir);
|
|
117
|
+
|
|
118
|
+
// Clean children sessions directory
|
|
119
|
+
const childrenDir = path.join(config.projectRoot, "sessions", "children");
|
|
120
|
+
const children = cleanDirectory(childrenDir);
|
|
121
|
+
|
|
122
|
+
// Clean stale registry entries
|
|
123
|
+
const registryCleaned = cleanRegistry();
|
|
124
|
+
|
|
125
|
+
// Clean old bus messages
|
|
126
|
+
let busCleanup = { done: 0, failed: 0 };
|
|
127
|
+
try {
|
|
128
|
+
busCleanup = busCleanupFn();
|
|
129
|
+
} catch {}
|
|
130
|
+
|
|
131
|
+
const totalLogs = main.logsRemoved + children.logsRemoved;
|
|
132
|
+
const totalSessions = main.sessionsRemoved + children.sessionsRemoved;
|
|
133
|
+
const totalBus = busCleanup.done + busCleanup.failed;
|
|
134
|
+
|
|
135
|
+
if (totalLogs > 0 || totalSessions > 0 || registryCleaned > 0 || totalBus > 0) {
|
|
136
|
+
log.info("Cleanup complete", {
|
|
137
|
+
logsRemoved: totalLogs,
|
|
138
|
+
sessionsRemoved: totalSessions,
|
|
139
|
+
registryCleaned,
|
|
140
|
+
busMessagesRemoved: totalBus,
|
|
141
|
+
});
|
|
142
|
+
} else {
|
|
143
|
+
log.debug("Cleanup complete — nothing to remove");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let _interval = null;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Start the background log collector.
|
|
151
|
+
* Runs immediately (after 5s delay), then every CLEANUP_INTERVAL_MIN minutes.
|
|
152
|
+
*/
|
|
153
|
+
export function startLogCollector() {
|
|
154
|
+
// Initial run after 5 seconds (let the app finish starting up)
|
|
155
|
+
const initialTimer = setTimeout(runCollector, 5000);
|
|
156
|
+
initialTimer.unref();
|
|
157
|
+
|
|
158
|
+
// Periodic run
|
|
159
|
+
_interval = setInterval(runCollector, CLEANUP_INTERVAL_MIN * 60000);
|
|
160
|
+
_interval.unref(); // Don't keep process alive just for cleanup
|
|
161
|
+
|
|
162
|
+
log.info("Log collector started", {
|
|
163
|
+
logRetentionDays: LOG_RETENTION_DAYS,
|
|
164
|
+
sessionRetentionDays: SESSION_RETENTION_DAYS,
|
|
165
|
+
intervalMin: CLEANUP_INTERVAL_MIN,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Stop the background log collector.
|
|
171
|
+
*/
|
|
172
|
+
export function stopLogCollector() {
|
|
173
|
+
if (_interval) {
|
|
174
|
+
clearInterval(_interval);
|
|
175
|
+
_interval = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Run collector once (for manual/command use).
|
|
181
|
+
*/
|
|
182
|
+
export { runCollector };
|