@lazyingart/agintiflow 0.20.45 → 0.20.47

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.
@@ -0,0 +1,39 @@
1
+ # AgInTiFlow Housekeeping
2
+
3
+ AgInTiFlow keeps full private session history in `~/.agintiflow/sessions/<session-id>/`.
4
+ That history can include user prompts, model text, tool arguments, file diffs, and artifacts, so it is local-only and should not be committed.
5
+
6
+ Housekeeping is a lighter background layer for reusable learning:
7
+
8
+ - It listens to session events without blocking the main agent loop.
9
+ - It writes sanitized cross-session records to `~/.agintiflow/housekeeping/events.jsonl`.
10
+ - It maintains aggregate model, tool, and skill usage in `~/.agintiflow/housekeeping/capabilities.json`.
11
+ - It redacts common token/key/password patterns and replaces local project/home paths with placeholders.
12
+ - It stores previews, hashes, counts, model/tool names, selected skill ids, and touched skill files rather than a full second transcript.
13
+
14
+ Use:
15
+
16
+ ```bash
17
+ aginti housekeeping
18
+ aginti housekeeping --json
19
+ ```
20
+
21
+ Disable local housekeeping for a run:
22
+
23
+ ```bash
24
+ AGINTIFLOW_HOUSEKEEPING=0 aginti
25
+ ```
26
+
27
+ Project session pointers live in `.aginti-sessions/`, and old `.sessions/` folders may still exist after migration.
28
+ AgInTiFlow adds both to `.gitignore` during `aginti init` and when session storage is prepared, because these folders can contain private local metadata.
29
+
30
+ ## Sharing Without A Center Server
31
+
32
+ Without a central server, the safe sharing path is:
33
+
34
+ 1. Keep raw sessions and artifacts local.
35
+ 2. Aggregate local learning into sanitized housekeeping capability data.
36
+ 3. Promote reviewed reusable knowledge into Markdown skills, task profiles, command policy, or docs.
37
+ 4. Ship those reviewed files through the npm package.
38
+
39
+ This avoids uploading private transcripts while still letting users receive better skills and tools when they upgrade `@lazyingart/agintiflow`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.45",
3
+ "version": "0.20.47",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a web-first coding agent and CLI with DeepSeek routing, sandboxed tools, model providers, canvas artifacts, and optional wrappers.",
6
6
  "license": "Apache-2.0",
@@ -11,9 +11,11 @@ import {
11
11
  buildPromptRenderSequence,
12
12
  canonicalSlashPromptBuffer,
13
13
  classifyEscapeAction,
14
+ formatElapsedDuration,
14
15
  formatWorkspaceChange,
15
16
  stripMarkdown,
16
17
  } from "../src/interactive-cli.js";
18
+ import { dockerPolicyTimeoutMs, dockerUserCommand } from "../src/docker-sandbox.js";
17
19
 
18
20
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
19
21
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-cli-chat-"));
@@ -135,6 +137,24 @@ try {
135
137
  if (!launchHeader.includes("█████") || !launchHeader.includes("v0.0.0") || launchHeader.split("\n").length < 9) {
136
138
  throw new Error("large launch header did not render a centered multi-line title");
137
139
  }
140
+ if (
141
+ formatElapsedDuration(0) !== "00:00" ||
142
+ formatElapsedDuration(65_000) !== "01:05" ||
143
+ formatElapsedDuration(3_665_000) !== "1:01:05"
144
+ ) {
145
+ throw new Error("elapsed duration formatter returned an unexpected value");
146
+ }
147
+ const wrappedDockerCommand = dockerUserCommand("node --test 2>&1 | tail -30", {
148
+ category: "general-shell",
149
+ needsNetwork: false,
150
+ });
151
+ if (
152
+ dockerPolicyTimeoutMs({ needsNetwork: true }) !== 120000 ||
153
+ !wrappedDockerCommand.includes("timeout -k 5s 15s bash -lc") ||
154
+ !wrappedDockerCommand.includes("'node --test 2>&1 | tail -30'")
155
+ ) {
156
+ throw new Error("docker sandbox command wrapper did not add a bounded inner timeout");
157
+ }
138
158
 
139
159
  const promptLayout = buildPromptLayout(`${"x".repeat(180)}\nsecond line`, 95, 80, 24);
140
160
  const promptText = promptLayout.renderedRows
@@ -10,6 +10,7 @@ import {
10
10
  sessionStoreOptions,
11
11
  } from "../src/project.js";
12
12
  import { SessionStore } from "../src/session-store.js";
13
+ import { flushHousekeeping, readHousekeepingSummary } from "../src/housekeeping.js";
13
14
 
14
15
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-inbox-"));
15
16
  process.env.AGINTIFLOW_HOME = path.join(tempRoot, ".agintiflow-home");
@@ -53,6 +54,8 @@ try {
53
54
  );
54
55
  await fs.writeFile(path.join(legacyDir, "events.jsonl"), "", "utf8");
55
56
  const paths = await ensureProjectSessionStorage(legacyProject);
57
+ const gitignore = await fs.readFile(path.join(legacyProject, ".gitignore"), "utf8");
58
+ assert(gitignore.includes(".aginti-sessions/") && gitignore.includes(".sessions/"), "session folders were not protected by .gitignore");
56
59
  const migrated = await listProjectSessions(legacyProject, 10);
57
60
  assert(migrated.some((session) => session.sessionId === legacySession), "legacy session was not discoverable after migration");
58
61
  assert(
@@ -125,6 +128,28 @@ try {
125
128
  "empty session project pointer was not removed"
126
129
  );
127
130
 
131
+ const housekeepingStore = new SessionStore(paths.globalSessionsDir, "housekeeping-smoke", sessionStoreOptions(legacyProject, "housekeeping-smoke"));
132
+ await housekeepingStore.appendEvent("skills.selected", {
133
+ taskProfile: "website",
134
+ skills: ["website-app", "code-review"],
135
+ goal: "build a small website with token=secret-value",
136
+ });
137
+ await housekeepingStore.appendEvent("model.responded", {
138
+ step: 1,
139
+ content: "Use the local project at /tmp/private-project and avoid api_key=abc123456789.",
140
+ toolCalls: [{ id: "call-secret", name: "run_command", arguments: "{\"command\":\"echo hi\"}" }],
141
+ });
142
+ await housekeepingStore.appendEvent("tool.started", {
143
+ toolName: "run_command",
144
+ args: { command: "echo token=secret-value" },
145
+ });
146
+ await flushHousekeeping();
147
+ const housekeeping = await readHousekeepingSummary();
148
+ assert(housekeeping.capabilities?.totals?.skillSelections >= 1, "housekeeping did not aggregate selected skills");
149
+ assert(housekeeping.capabilities?.tools?.run_command?.count >= 1, "housekeeping did not aggregate tool usage");
150
+ const housekeepingEvents = await fs.readFile(housekeeping.paths.eventsPath, "utf8");
151
+ assert(!housekeepingEvents.includes("secret-value") && !housekeepingEvents.includes("abc123456789"), "housekeeping leaked raw secret text");
152
+
128
153
  console.log(
129
154
  JSON.stringify(
130
155
  {
@@ -139,6 +164,8 @@ try {
139
164
  "all-sessions-list",
140
165
  "empty-session-detection",
141
166
  "empty-session-removal",
167
+ "session-gitignore-protection",
168
+ "housekeeping-redacted-learning-log",
142
169
  ],
143
170
  },
144
171
  null,
@@ -26,6 +26,7 @@ import { formatSkillsForPrompt, selectSkillsForGoal } from "./skill-library.js";
26
26
  import { hostShellOption, platformInfo, platformLabel } from "./platform.js";
27
27
  import { captureTmuxPane, listTmuxSessions, sendTmuxKeys, startTmuxSession } from "./tmux-tools.js";
28
28
  import { languageInstruction } from "./i18n.js";
29
+ import { flushHousekeeping } from "./housekeeping.js";
29
30
 
30
31
  const exec = promisify(execCallback);
31
32
  const BROWSER_TOOLS = new Set(["open_url", "open_workspace_file", "preview_workspace", "click", "type", "scroll", "press", "back"]);
@@ -1256,6 +1257,7 @@ export async function runAgent(config) {
1256
1257
  const sessionId = config.resume || config.sessionId || `web-agent-${crypto.randomUUID()}`;
1257
1258
  const store = new SessionStore(config.sessionsDir, sessionId, {
1258
1259
  projectRoot: config.baseDir,
1260
+ commandCwd: config.commandCwd,
1259
1261
  projectSessionsDir: config.projectSessionsDir,
1260
1262
  });
1261
1263
  const client = createClient(config);
@@ -1283,6 +1285,11 @@ export async function runAgent(config) {
1283
1285
  routeReason: config.routeReason,
1284
1286
  goal: config.goal,
1285
1287
  });
1288
+ await store.appendEvent("skills.selected", {
1289
+ taskProfile: config.taskProfile,
1290
+ skills: state.meta.selectedSkills || [],
1291
+ goal: config.goal,
1292
+ });
1286
1293
  await store.saveState(state);
1287
1294
  } else {
1288
1295
  await store.appendEvent("session.resumed", { sessionId });
@@ -1650,5 +1657,6 @@ export async function runAgent(config) {
1650
1657
  };
1651
1658
  } finally {
1652
1659
  await closeBrowser(browserState, store);
1660
+ await flushHousekeeping();
1653
1661
  }
1654
1662
  }
package/src/cli.js CHANGED
@@ -32,6 +32,7 @@ import { normalizeAuthProvider, promptHidden, runAuthWizard, shouldPromptForDeep
32
32
  import { listSkills, selectSkillsForGoal } from "./skill-library.js";
33
33
  import { languageLabel, resolveLanguage } from "./i18n.js";
34
34
  import { maybeAutoUpdate } from "./auto-update.js";
35
+ import { readHousekeepingSummary } from "./housekeeping.js";
35
36
  import fs from "node:fs/promises";
36
37
  import path from "node:path";
37
38
  import { fileURLToPath } from "node:url";
@@ -381,7 +382,7 @@ export function parseArgs(argv) {
381
382
 
382
383
  function printUsage() {
383
384
  console.log(
384
- 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti update OR aginti models OR aginti skills [query] OR aginti auth [deepseek|openai|qwen|venice|grsai] OR aginti resume [--all-sessions] [latest|<session-id>] ["prompt"] OR aginti --remove-empty-sessions OR aginti --remove-sessions OR aginti queue <session-id> "message" OR aginti [--no-auto-update] [--language en|ja|zh-Hans|zh-Hant|ko|fr|es|ar|vi|de|ru] [--image] [--latex] [--routing smart|fast|complex|manual] [--provider deepseek|openai|qwen|venice|mock] [--model MODEL] [--route-model MODEL] [--main-model MODEL] [--spare-model MODEL --spare-reasoning medium] [--aux-provider grsai|venice --aux-model MODEL] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-file-tools|--no-file-tools] [--web-search|--no-web-search] [--parallel-scouts|--no-parallel-scouts --scout-count 1..10] [--allow-auxiliary-tools|--no-auxiliary-tools] [--allow-wrappers --wrapper codex --wrapper-model gpt-5.5] [--list-models|--list-routes] "your task"'
385
+ 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti update OR aginti models OR aginti skills [query] OR aginti housekeeping [--json] OR aginti auth [deepseek|openai|qwen|venice|grsai] OR aginti resume [--all-sessions] [latest|<session-id>] ["prompt"] OR aginti --remove-empty-sessions OR aginti --remove-sessions OR aginti queue <session-id> "message" OR aginti [--no-auto-update] [--language en|ja|zh-Hans|zh-Hant|ko|fr|es|ar|vi|de|ru] [--image] [--latex] [--routing smart|fast|complex|manual] [--provider deepseek|openai|qwen|venice|mock] [--model MODEL] [--route-model MODEL] [--main-model MODEL] [--spare-model MODEL --spare-reasoning medium] [--aux-provider grsai|venice --aux-model MODEL] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-file-tools|--no-file-tools] [--web-search|--no-web-search] [--parallel-scouts|--no-parallel-scouts --scout-count 1..10] [--allow-auxiliary-tools|--no-auxiliary-tools] [--allow-wrappers --wrapper codex --wrapper-model gpt-5.5] [--list-models|--list-routes] "your task"'
385
386
  );
386
387
  console.log(`Languages: ${["en", "ja", "zh-Hans", "zh-Hant", "ko", "fr", "es", "ar", "vi", "de", "ru"].map((code) => `${code}=${languageLabel(code)}`).join(", ")}`);
387
388
  }
@@ -499,6 +500,35 @@ function printSkills(query = "") {
499
500
  }
500
501
  }
501
502
 
503
+ async function printHousekeeping(argv = []) {
504
+ const summary = await readHousekeepingSummary();
505
+ if (argv.includes("--json")) {
506
+ console.log(JSON.stringify(summary, null, 2));
507
+ return;
508
+ }
509
+ const totals = summary.capabilities?.totals || {};
510
+ console.log("AgInTiFlow housekeeping");
511
+ console.log(`root=${summary.paths.root}`);
512
+ console.log(`events=${summary.paths.eventsPath}`);
513
+ console.log(`capabilities=${summary.paths.capabilitiesPath}`);
514
+ console.log(
515
+ `totals events=${totals.events || 0} modelRequests=${totals.modelRequests || 0} toolEvents=${totals.toolEvents || 0} skillSelections=${totals.skillSelections || 0}`
516
+ );
517
+ const tools = Object.entries(summary.capabilities?.tools || {})
518
+ .sort((a, b) => (b[1].count || 0) - (a[1].count || 0))
519
+ .slice(0, 8)
520
+ .map(([name, item]) => `${name}:${item.count || 0}`)
521
+ .join(" ");
522
+ const skills = Object.entries(summary.capabilities?.skills || {})
523
+ .sort((a, b) => (b[1].count || 0) - (a[1].count || 0))
524
+ .slice(0, 8)
525
+ .map(([name, item]) => `${name}:${item.count || 0}`)
526
+ .join(" ");
527
+ if (tools) console.log(`topTools ${tools}`);
528
+ if (skills) console.log(`topSkills ${skills}`);
529
+ console.log("Set AGINTIFLOW_HOUSEKEEPING=0 to disable local sanitized housekeeping logs.");
530
+ }
531
+
502
532
  function printInitResult(result) {
503
533
  console.log(`AgInTiFlow project initialized: ${result.projectRoot}`);
504
534
  console.log(`instructions=${result.instructionsPath}`);
@@ -1056,6 +1086,11 @@ export async function main(argv = process.argv.slice(2)) {
1056
1086
  return;
1057
1087
  }
1058
1088
 
1089
+ if (argv[0] === "housekeeping" || argv[0] === "housekeeper") {
1090
+ await printHousekeeping(argv.slice(1));
1091
+ return;
1092
+ }
1093
+
1059
1094
  if (argv[0] === "keys/status") {
1060
1095
  await handleKeyCommand(["status"]);
1061
1096
  return;
@@ -29,6 +29,28 @@ function shellEscape(value) {
29
29
  return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
30
30
  }
31
31
 
32
+ export function dockerPolicyTimeoutMs(policy = {}) {
33
+ if (policy.needsNetwork) return 120000;
34
+ if (policy.category === "toolchain") return 90000;
35
+ return 15000;
36
+ }
37
+
38
+ function dockerExecTimeoutMs(policy = {}) {
39
+ return dockerPolicyTimeoutMs(policy) + 5000;
40
+ }
41
+
42
+ export function dockerUserCommand(command, policy = {}) {
43
+ const seconds = Math.max(1, Math.ceil(dockerPolicyTimeoutMs(policy) / 1000));
44
+ const escapedCommand = shellEscape(String(command || ""));
45
+ return [
46
+ `if command -v timeout >/dev/null 2>&1; then`,
47
+ ` timeout -k 5s ${seconds}s bash -lc ${escapedCommand}`,
48
+ `else`,
49
+ ` bash -lc ${escapedCommand}`,
50
+ `fi`,
51
+ ].join("\n");
52
+ }
53
+
32
54
  function buildDockerInvocation(args) {
33
55
  return ["docker", ...args].map(shellEscape).join(" ");
34
56
  }
@@ -232,7 +254,7 @@ function dockerCommand(command, policy) {
232
254
  );
233
255
  }
234
256
 
235
- return [...envLines, String(command)].join("\n");
257
+ return [...envLines, dockerUserCommand(command, policy)].join("\n");
236
258
  }
237
259
 
238
260
  function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command, config), persistentDirs = persistentDockerDirs(config)) {
@@ -289,7 +311,7 @@ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command,
289
311
  export async function runDockerSandboxCommand(command, config, policy = evaluateCommandPolicy(command, config), options = {}) {
290
312
  const persistentDirs = await ensurePersistentDockerDirs(config);
291
313
  const result = await execDocker(dockerRunArgs(command, config, policy, persistentDirs), {
292
- timeout: policy.needsNetwork ? 120000 : policy.category === "toolchain" ? 90000 : 15000,
314
+ timeout: dockerExecTimeoutMs(policy),
293
315
  maxBuffer: 300 * 1024,
294
316
  signal: options.signal,
295
317
  });
@@ -366,7 +388,7 @@ export async function runDockerPreflight(config, options = {}) {
366
388
  ]) {
367
389
  try {
368
390
  const result = await execDocker(dockerRunArgs(command, config, { needsNetwork: false, category: "preflight" }, persistentDirs), {
369
- timeout: 15000,
391
+ timeout: dockerExecTimeoutMs({ needsNetwork: false, category: "preflight" }),
370
392
  maxBuffer: 100 * 1024,
371
393
  });
372
394
  results.push({ command, ok: true, stdout: result.stdout.trim(), stderr: result.stderr.trim() });
@@ -0,0 +1,337 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import { agintiflowHome } from "./session-index.js";
6
+ import { redactSensitiveText, redactValue } from "./redaction.js";
7
+
8
+ const MAX_PREVIEW_CHARS = 360;
9
+ const MAX_EVENTS_FILE_BYTES = 8 * 1024 * 1024;
10
+ let queue = Promise.resolve();
11
+
12
+ function enabled() {
13
+ return String(process.env.AGINTIFLOW_HOUSEKEEPING || "1").toLowerCase() !== "0";
14
+ }
15
+
16
+ export function housekeepingPaths(home = agintiflowHome()) {
17
+ const root = path.join(home, "housekeeping");
18
+ return {
19
+ root,
20
+ eventsPath: path.join(root, "events.jsonl"),
21
+ capabilitiesPath: path.join(root, "capabilities.json"),
22
+ readmePath: path.join(root, "README.md"),
23
+ };
24
+ }
25
+
26
+ function hashText(value = "") {
27
+ return crypto.createHash("sha256").update(String(value || "")).digest("hex").slice(0, 16);
28
+ }
29
+
30
+ function redactContextText(value = "", context = {}) {
31
+ let text = redactSensitiveText(value);
32
+ const replacements = [
33
+ [context.projectRoot, "$PROJECT"],
34
+ [context.commandCwd, "$CWD"],
35
+ [os.homedir(), "~"],
36
+ ].filter(([needle]) => needle && typeof needle === "string");
37
+ for (const [needle, replacement] of replacements) {
38
+ text = text.split(needle).join(replacement);
39
+ }
40
+ return text;
41
+ }
42
+
43
+ function previewText(value = "", context = {}, limit = MAX_PREVIEW_CHARS) {
44
+ const text = redactContextText(String(value ?? ""), context).replace(/\s+/g, " ").trim();
45
+ return text.length <= limit ? text : `${text.slice(0, Math.max(limit - 1, 1))}...`;
46
+ }
47
+
48
+ function sanitizePath(value = "", context = {}) {
49
+ const text = redactContextText(String(value || ""), context);
50
+ if (!text) return "";
51
+ if (text.startsWith("$PROJECT/") || text.startsWith("$CWD/") || text.startsWith("~/")) return text;
52
+ if (path.isAbsolute(text)) return path.basename(text);
53
+ return text;
54
+ }
55
+
56
+ function safeArray(value) {
57
+ return Array.isArray(value) ? value.filter(Boolean) : [];
58
+ }
59
+
60
+ function safeProjectName(projectRoot = "") {
61
+ return projectRoot ? path.basename(projectRoot) : "";
62
+ }
63
+
64
+ function sanitizeToolCalls(toolCalls = [], context = {}) {
65
+ return safeArray(toolCalls).slice(0, 20).map((call) => {
66
+ const name = call.name || call.function?.name || "";
67
+ const args = call.arguments || call.function?.arguments || "";
68
+ return {
69
+ id: call.id ? hashText(call.id) : "",
70
+ name,
71
+ argumentsPreview: previewText(args, context, 260),
72
+ argumentsHash: args ? hashText(args) : "",
73
+ };
74
+ });
75
+ }
76
+
77
+ function sanitizeEvent(type, data = {}, context = {}) {
78
+ const redacted = redactValue(data || {});
79
+ const base = {
80
+ type,
81
+ sessionId: context.sessionId || "",
82
+ projectHash: context.projectRoot ? hashText(path.resolve(context.projectRoot)) : "",
83
+ projectName: safeProjectName(context.projectRoot),
84
+ };
85
+
86
+ if (type === "session.created" || type === "session.resumed" || type === "session.finished" || type === "session.stopped") {
87
+ return {
88
+ ...base,
89
+ provider: redacted.provider || "",
90
+ model: redacted.model || "",
91
+ routingMode: redacted.routingMode || "",
92
+ routeReason: redacted.routeReason || "",
93
+ reason: redacted.reason || "",
94
+ mode: redacted.mode || "",
95
+ goalPreview: previewText(redacted.goal || "", context),
96
+ goalHash: redacted.goal ? hashText(redacted.goal) : "",
97
+ resultPreview: previewText(redacted.result || "", context),
98
+ resultHash: redacted.result ? hashText(redacted.result) : "",
99
+ };
100
+ }
101
+
102
+ if (type === "skills.selected") {
103
+ return {
104
+ ...base,
105
+ taskProfile: redacted.taskProfile || "",
106
+ skills: safeArray(redacted.skills).map(String).slice(0, 30),
107
+ goalHash: redacted.goal ? hashText(redacted.goal) : "",
108
+ };
109
+ }
110
+
111
+ if (type === "model.requested") {
112
+ return {
113
+ ...base,
114
+ step: redacted.step || 0,
115
+ provider: redacted.provider || "",
116
+ model: redacted.model || "",
117
+ };
118
+ }
119
+
120
+ if (type === "model.responded") {
121
+ const content = String(redacted.content || "");
122
+ return {
123
+ ...base,
124
+ step: redacted.step || 0,
125
+ contentPreview: previewText(content, context),
126
+ contentHash: content ? hashText(content) : "",
127
+ contentChars: content.length,
128
+ toolCalls: sanitizeToolCalls(redacted.toolCalls, context),
129
+ };
130
+ }
131
+
132
+ if (type.startsWith("tool.")) {
133
+ return {
134
+ ...base,
135
+ toolName: redacted.toolName || "",
136
+ ok: redacted.ok,
137
+ blocked: Boolean(redacted.blocked),
138
+ category: redacted.commandPolicy?.category || redacted.category || "",
139
+ commandPreview: previewText(redacted.args?.command || redacted.command || "", context, 260),
140
+ path: sanitizePath(redacted.path || redacted.args?.path || "", context),
141
+ stdoutPreview: previewText(redacted.stdout || "", context, 220),
142
+ stderrPreview: previewText(redacted.stderr || redacted.error || redacted.reason || "", context, 220),
143
+ };
144
+ }
145
+
146
+ if (type === "file.changed") {
147
+ const diff = String(redacted.diff || "");
148
+ return {
149
+ ...base,
150
+ toolName: redacted.toolName || "",
151
+ path: sanitizePath(redacted.path || "", context),
152
+ beforeHash: redacted.beforeHash || "",
153
+ afterHash: redacted.afterHash || "",
154
+ diffPreview: previewText(diff, context, 260),
155
+ diffHash: diff ? hashText(diff) : "",
156
+ skillTouched: /^skills\/[^/]+\/SKILL\.md$/.test(String(redacted.path || "")),
157
+ };
158
+ }
159
+
160
+ if (type === "plan.created") {
161
+ const plan = String(redacted.plan || "");
162
+ return {
163
+ ...base,
164
+ planPreview: previewText(plan, context),
165
+ planHash: plan ? hashText(plan) : "",
166
+ };
167
+ }
168
+
169
+ if (type === "parallel_scouts.completed") {
170
+ return {
171
+ ...base,
172
+ model: redacted.model || "",
173
+ requested: redacted.requested || 0,
174
+ completed: redacted.completed || 0,
175
+ scoutNames: safeArray(redacted.scouts).map((scout) => scout.name || "").filter(Boolean).slice(0, 20),
176
+ synthesisHash: redacted.synthesis ? hashText(redacted.synthesis) : "",
177
+ };
178
+ }
179
+
180
+ if (type.startsWith("canvas.") || type === "image.generated") {
181
+ return {
182
+ ...base,
183
+ kind: redacted.kind || "",
184
+ title: previewText(redacted.title || "", context, 160),
185
+ path: sanitizePath(redacted.path || redacted.outputPath || redacted.artifactPath || "", context),
186
+ notePreview: previewText(redacted.note || redacted.preview || "", context, 220),
187
+ };
188
+ }
189
+
190
+ return {
191
+ ...base,
192
+ dataPreview: previewText(JSON.stringify(redacted), context),
193
+ dataHash: hashText(JSON.stringify(redacted)),
194
+ };
195
+ }
196
+
197
+ function emptyCapabilities() {
198
+ return {
199
+ version: 1,
200
+ updatedAt: "",
201
+ totals: {
202
+ events: 0,
203
+ sessions: 0,
204
+ modelRequests: 0,
205
+ modelResponses: 0,
206
+ toolEvents: 0,
207
+ skillSelections: 0,
208
+ skillFileChanges: 0,
209
+ },
210
+ models: {},
211
+ tools: {},
212
+ skills: {},
213
+ sessions: {},
214
+ projects: {},
215
+ };
216
+ }
217
+
218
+ async function readCapabilities(paths) {
219
+ try {
220
+ return JSON.parse(await fs.readFile(paths.capabilitiesPath, "utf8"));
221
+ } catch {
222
+ return emptyCapabilities();
223
+ }
224
+ }
225
+
226
+ function bumpCounter(map, key, patch = {}) {
227
+ if (!key) return;
228
+ const current = map[key] || { count: 0 };
229
+ map[key] = {
230
+ ...current,
231
+ ...patch,
232
+ count: (current.count || 0) + 1,
233
+ lastSeenAt: patch.lastSeenAt || new Date().toISOString(),
234
+ };
235
+ }
236
+
237
+ async function maybeRotateEvents(paths) {
238
+ const stat = await fs.stat(paths.eventsPath).catch(() => null);
239
+ if (!stat || stat.size < MAX_EVENTS_FILE_BYTES) return;
240
+ const rotated = path.join(paths.root, `events-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
241
+ await fs.rename(paths.eventsPath, rotated).catch(() => {});
242
+ }
243
+
244
+ async function ensureReadme(paths) {
245
+ await fs.writeFile(
246
+ paths.readmePath,
247
+ [
248
+ "# AgInTiFlow Housekeeping",
249
+ "",
250
+ "This folder stores local, redacted learning logs derived from session events.",
251
+ "",
252
+ "- `events.jsonl` is a sanitized cross-session event feed.",
253
+ "- `capabilities.json` aggregates models, tools, selected skills, and touched skill files.",
254
+ "- Full session history remains in `~/.agintiflow/sessions/<session-id>/` and should not be committed.",
255
+ "",
256
+ "The housekeeping files are local by default. Share only reviewed, sanitized capability packs.",
257
+ "",
258
+ ].join("\n"),
259
+ "utf8"
260
+ ).catch(() => {});
261
+ }
262
+
263
+ async function recordEvent(record) {
264
+ const paths = housekeepingPaths();
265
+ await fs.mkdir(paths.root, { recursive: true });
266
+ await ensureReadme(paths);
267
+ await maybeRotateEvents(paths);
268
+ const timestamp = record.timestamp || new Date().toISOString();
269
+ await fs.appendFile(paths.eventsPath, `${JSON.stringify({ timestamp, ...record })}\n`, "utf8");
270
+
271
+ const capabilities = await readCapabilities(paths);
272
+ capabilities.updatedAt = timestamp;
273
+ capabilities.totals.events = (capabilities.totals.events || 0) + 1;
274
+ bumpCounter(capabilities.projects, record.projectHash || "unknown", {
275
+ name: record.projectName || "",
276
+ lastSeenAt: timestamp,
277
+ });
278
+
279
+ capabilities.sessions ||= {};
280
+ if (record.sessionId) {
281
+ capabilities.sessions[record.sessionId] = {
282
+ projectHash: record.projectHash || "",
283
+ lastSeenAt: timestamp,
284
+ };
285
+ capabilities.totals.sessions = Object.keys(capabilities.sessions).length;
286
+ }
287
+ if (record.type === "model.requested") {
288
+ capabilities.totals.modelRequests = (capabilities.totals.modelRequests || 0) + 1;
289
+ bumpCounter(capabilities.models, `${record.provider}/${record.model}`, { lastSeenAt: timestamp });
290
+ }
291
+ if (record.type === "model.responded") {
292
+ capabilities.totals.modelResponses = (capabilities.totals.modelResponses || 0) + 1;
293
+ }
294
+ if (record.type?.startsWith("tool.")) {
295
+ capabilities.totals.toolEvents = (capabilities.totals.toolEvents || 0) + 1;
296
+ bumpCounter(capabilities.tools, record.toolName || "unknown", {
297
+ category: record.category || "",
298
+ lastSeenAt: timestamp,
299
+ });
300
+ }
301
+ if (record.type === "skills.selected") {
302
+ capabilities.totals.skillSelections = (capabilities.totals.skillSelections || 0) + 1;
303
+ for (const skill of record.skills || []) {
304
+ bumpCounter(capabilities.skills, skill, { source: "selected", lastSeenAt: timestamp });
305
+ }
306
+ }
307
+ if (record.type === "file.changed" && record.skillTouched) {
308
+ capabilities.totals.skillFileChanges = (capabilities.totals.skillFileChanges || 0) + 1;
309
+ const skillId = String(record.path || "").split("/")[1] || "unknown";
310
+ bumpCounter(capabilities.skills, skillId, { source: "skill-file-change", lastSeenAt: timestamp });
311
+ }
312
+
313
+ await fs.writeFile(paths.capabilitiesPath, `${JSON.stringify(capabilities, null, 2)}\n`, "utf8");
314
+ }
315
+
316
+ export function enqueueHousekeepingEvent({ sessionId = "", projectRoot = "", commandCwd = "", event = {} } = {}) {
317
+ if (!enabled()) return;
318
+ const timestamp = event.timestamp || new Date().toISOString();
319
+ const context = { sessionId, projectRoot, commandCwd };
320
+ const record = {
321
+ timestamp,
322
+ ...sanitizeEvent(event.type || "event", event.data || {}, context),
323
+ };
324
+ queue = queue.then(() => recordEvent(record)).catch(() => {});
325
+ }
326
+
327
+ export async function flushHousekeeping() {
328
+ await queue.catch(() => {});
329
+ }
330
+
331
+ export async function readHousekeepingSummary() {
332
+ const paths = housekeepingPaths();
333
+ return {
334
+ paths,
335
+ capabilities: await readCapabilities(paths),
336
+ };
337
+ }
@@ -236,6 +236,15 @@ function compactLine(value = "", limit = 96) {
236
236
  return text.length <= limit ? text : `${text.slice(0, Math.max(limit - 1, 1))}…`;
237
237
  }
238
238
 
239
+ export function formatElapsedDuration(ms = 0) {
240
+ const totalSeconds = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
241
+ const seconds = totalSeconds % 60;
242
+ const minutes = Math.floor(totalSeconds / 60) % 60;
243
+ const hours = Math.floor(totalSeconds / 3600);
244
+ const two = (value) => String(value).padStart(2, "0");
245
+ return hours > 0 ? `${hours}:${two(minutes)}:${two(seconds)}` : `${two(minutes)}:${two(seconds)}`;
246
+ }
247
+
239
248
  function wrapTextLine(value = "", width = 72) {
240
249
  const text = stripAnsi(String(value || ""));
241
250
  if (text.length <= width) return [text];
@@ -1254,6 +1263,8 @@ class LiveRunInput {
1254
1263
  this.pendingAsap = [];
1255
1264
  this.pendingQueued = [];
1256
1265
  this.statusLine = "";
1266
+ this.statusStartedAt = 0;
1267
+ this.statusTimer = null;
1257
1268
  this.wasRaw = Boolean(input.isRaw);
1258
1269
  this.started = false;
1259
1270
  this.handler = this.handleKey.bind(this);
@@ -1270,6 +1281,11 @@ class LiveRunInput {
1270
1281
  input.setRawMode(true);
1271
1282
  input.on("keypress", this.handler);
1272
1283
  activeRunInput = this;
1284
+ this.statusStartedAt = Date.now();
1285
+ this.statusTimer = setInterval(() => {
1286
+ if (this.started && this.statusLine) this.renderNow();
1287
+ }, 1000);
1288
+ this.statusTimer.unref?.();
1273
1289
  this.started = true;
1274
1290
  this.renderNow();
1275
1291
  return true;
@@ -1281,6 +1297,10 @@ class LiveRunInput {
1281
1297
  clearImmediate(this.redrawHandle);
1282
1298
  this.redrawHandle = null;
1283
1299
  }
1300
+ if (this.statusTimer) {
1301
+ clearInterval(this.statusTimer);
1302
+ this.statusTimer = null;
1303
+ }
1284
1304
  input.off("keypress", this.handler);
1285
1305
  if (typeof input.setRawMode === "function") input.setRawMode(this.wasRaw);
1286
1306
  this.clearForExternalOutput();
@@ -1312,6 +1332,12 @@ class LiveRunInput {
1312
1332
  output.write(ansi.cursorShow);
1313
1333
  }
1314
1334
 
1335
+ currentStatusLine() {
1336
+ if (!this.statusLine) return "";
1337
+ const elapsed = formatElapsedDuration(Date.now() - (this.statusStartedAt || Date.now()));
1338
+ return compactLine(`${elapsed} · ${this.statusLine}`, Math.max(terminalWidth() - 16, 36));
1339
+ }
1340
+
1315
1341
  renderNow() {
1316
1342
  if (this.redrawHandle) {
1317
1343
  clearImmediate(this.redrawHandle);
@@ -1320,7 +1346,7 @@ class LiveRunInput {
1320
1346
  this.rendered = renderPromptBuffer(this.buffer, this.cursor, this.rendered, {
1321
1347
  commandCwd: this.commandCwd,
1322
1348
  language: this.state.language || cliLanguage,
1323
- statusLine: this.statusLine,
1349
+ statusLine: this.currentStatusLine(),
1324
1350
  pendingAsap: this.pendingAsap,
1325
1351
  pendingQueued: this.pendingQueued,
1326
1352
  });
@@ -1352,7 +1378,7 @@ class LiveRunInput {
1352
1378
  const layout = buildPromptLayout(this.buffer, this.cursor, terminalWidth(), terminalHeight(), {
1353
1379
  commandCwd: this.commandCwd,
1354
1380
  language: this.state.language || cliLanguage,
1355
- statusLine: this.statusLine,
1381
+ statusLine: this.currentStatusLine(),
1356
1382
  pendingAsap: this.pendingAsap,
1357
1383
  pendingQueued: this.pendingQueued,
1358
1384
  });
@@ -2469,7 +2495,7 @@ function buildReviewPrompt(focus = "") {
2469
2495
  "3. Use `inspect_project`, `search_files`, and targeted `read_file`; avoid full-tree dumps. Prefer precise symbol/error searches over opening many files.",
2470
2496
  "4. Exclude generated, vendored, binary, cache, and large artifact paths: .git, node_modules, vendor, dist, build, out, target, coverage, .next, .turbo, .venv, __pycache__, .pytest_cache, .aginti-sessions, .sessions, artifacts, images/videos/PDFs unless directly relevant.",
2471
2497
  "5. Context budget: at most two discovery passes; at most 12 primary files read initially; expand only when a concrete risk requires neighboring code.",
2472
- "6. Check likely validation commands from manifests, but run only focused non-destructive checks when useful. Do not install packages or run long broad suites unless clearly justified.",
2498
+ "6. Check likely validation commands from manifests, but run only focused non-destructive checks when useful. Bound shell checks with `timeout 30s ...` when available, avoid broad watch/dev commands, and do not install packages or run long broad suites unless clearly justified.",
2473
2499
  "7. Stop when you have enough evidence. Do not keep scanning just because more files exist.",
2474
2500
  "",
2475
2501
  "Final answer format:",
@@ -2990,6 +3016,7 @@ async function runPrompt(prompt, state, packageDir) {
2990
3016
 
2991
3017
  const store = new SessionStore(config.sessionsDir, state.sessionId, {
2992
3018
  projectRoot: config.baseDir,
3019
+ commandCwd: config.commandCwd,
2993
3020
  projectSessionsDir: config.projectSessionsDir,
2994
3021
  });
2995
3022
  const liveInput = new LiveRunInput({ state, store, controller });
package/src/project.js CHANGED
@@ -82,6 +82,7 @@ export function sessionStoreOptions(projectRoot = process.cwd(), sessionId = "")
82
82
  const paths = projectPaths(projectRoot);
83
83
  return {
84
84
  projectRoot: paths.root,
85
+ commandCwd: paths.root,
85
86
  projectSessionsDir: paths.sessionsDir,
86
87
  legacySessionDir: sessionId ? path.join(paths.legacySessionsDir, sessionId) : "",
87
88
  };
@@ -91,6 +92,10 @@ export async function ensureProjectSessionStorage(projectRoot = process.cwd()) {
91
92
  const paths = projectPaths(projectRoot);
92
93
  await fsp.mkdir(paths.sessionsDir, { recursive: true });
93
94
  await fsp.mkdir(paths.globalSessionsDir, { recursive: true });
95
+ await ensureLine(paths.gitignorePath, [
96
+ ".aginti-sessions/",
97
+ ".sessions/",
98
+ ]).catch(() => {});
94
99
 
95
100
  const legacyEntries = await fsp.readdir(paths.legacySessionsDir, { withFileTypes: true }).catch(() => []);
96
101
  if (legacyEntries.length > 0) {
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import { deleteSessionIndex, globalSessionPaths, isSafeSessionId, upsertSessionIndex } from "./session-index.js";
5
+ import { enqueueHousekeepingEvent } from "./housekeeping.js";
5
6
 
6
7
  export class SessionStore {
7
8
  constructor(baseDir, sessionId, options = {}) {
@@ -9,6 +10,7 @@ export class SessionStore {
9
10
  this.sessionId = sessionId;
10
11
  const globalPaths = globalSessionPaths(sessionId);
11
12
  this.projectRoot = options.projectRoot ? path.resolve(options.projectRoot) : "";
13
+ this.commandCwd = options.commandCwd ? path.resolve(options.commandCwd) : this.projectRoot;
12
14
  this.projectSessionsDir = options.projectSessionsDir ? path.resolve(options.projectSessionsDir) : "";
13
15
  this.legacySessionDir = options.legacySessionDir ? path.resolve(options.legacySessionDir) : "";
14
16
  this.sessionDir = path.resolve(options.sessionDir || (baseDir ? path.join(this.baseDir, sessionId) : globalPaths.sessionDir));
@@ -99,12 +101,19 @@ export class SessionStore {
99
101
 
100
102
  async appendEvent(type, data = {}) {
101
103
  await this.ensure();
102
- const line = JSON.stringify({
104
+ const event = {
103
105
  timestamp: new Date().toISOString(),
104
106
  type,
105
107
  data,
106
- });
108
+ };
109
+ const line = JSON.stringify(event);
107
110
  await fs.appendFile(this.eventsPath, `${line}\n`, "utf8");
111
+ enqueueHousekeepingEvent({
112
+ sessionId: this.sessionId,
113
+ projectRoot: this.projectRoot,
114
+ commandCwd: this.commandCwd,
115
+ event,
116
+ });
108
117
  }
109
118
 
110
119
  async loadEvents() {