token-pilot 0.19.2 → 0.23.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/.claude-plugin/hooks/hooks.json +30 -0
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +165 -0
- package/README.md +194 -313
- package/dist/agents/tp-audit-scanner.md +49 -0
- package/dist/agents/tp-commit-writer.md +41 -0
- package/dist/agents/tp-dead-code-finder.md +43 -0
- package/dist/agents/tp-debugger.md +45 -0
- package/dist/agents/tp-history-explorer.md +43 -0
- package/dist/agents/tp-impact-analyzer.md +44 -0
- package/dist/agents/tp-migration-scout.md +43 -0
- package/dist/agents/tp-onboard.md +40 -0
- package/dist/agents/tp-pr-reviewer.md +41 -0
- package/dist/agents/tp-refactor-planner.md +42 -0
- package/dist/agents/tp-run.md +48 -0
- package/dist/agents/tp-session-restorer.md +47 -0
- package/dist/agents/tp-test-triage.md +40 -0
- package/dist/agents/tp-test-writer.md +46 -0
- package/dist/cli/agent-frontmatter.d.ts +48 -0
- package/dist/cli/agent-frontmatter.js +189 -0
- package/dist/cli/bless-agents.d.ts +65 -0
- package/dist/cli/bless-agents.js +307 -0
- package/dist/cli/claudeignore.d.ts +33 -0
- package/dist/cli/claudeignore.js +88 -0
- package/dist/cli/claudemd-hygiene.d.ts +26 -0
- package/dist/cli/claudemd-hygiene.js +43 -0
- package/dist/cli/doctor-drift.d.ts +31 -0
- package/dist/cli/doctor-drift.js +130 -0
- package/dist/cli/doctor-env-check.d.ts +25 -0
- package/dist/cli/doctor-env-check.js +91 -0
- package/dist/cli/install-agents.d.ts +108 -0
- package/dist/cli/install-agents.js +402 -0
- package/dist/cli/save-doc.d.ts +42 -0
- package/dist/cli/save-doc.js +145 -0
- package/dist/cli/scan-agents.d.ts +46 -0
- package/dist/cli/scan-agents.js +227 -0
- package/dist/cli/stats.d.ts +36 -0
- package/dist/cli/stats.js +131 -0
- package/dist/cli/typo-guard.d.ts +27 -0
- package/dist/cli/typo-guard.js +119 -0
- package/dist/cli/unbless-agents.d.ts +33 -0
- package/dist/cli/unbless-agents.js +85 -0
- package/dist/cli/uninstall-agents.d.ts +36 -0
- package/dist/cli/uninstall-agents.js +117 -0
- package/dist/config/defaults.d.ts +1 -1
- package/dist/config/defaults.js +14 -8
- package/dist/config/loader.d.ts +1 -1
- package/dist/config/loader.js +105 -11
- package/dist/core/context-registry.d.ts +16 -1
- package/dist/core/context-registry.js +60 -28
- package/dist/core/event-log.d.ts +79 -0
- package/dist/core/event-log.js +190 -0
- package/dist/core/session-registry.d.ts +43 -0
- package/dist/core/session-registry.js +113 -0
- package/dist/core/session-savings.d.ts +19 -0
- package/dist/core/session-savings.js +60 -0
- package/dist/handlers/session-budget.d.ts +32 -0
- package/dist/handlers/session-budget.js +61 -0
- package/dist/handlers/session-snapshot-persist.d.ts +22 -0
- package/dist/handlers/session-snapshot-persist.js +76 -0
- package/dist/hooks/adaptive-threshold.d.ts +27 -0
- package/dist/hooks/adaptive-threshold.js +46 -0
- package/dist/hooks/format-deny-message.d.ts +21 -0
- package/dist/hooks/format-deny-message.js +147 -0
- package/dist/hooks/installer.js +130 -31
- package/dist/hooks/path-safety.d.ts +16 -0
- package/dist/hooks/path-safety.js +34 -0
- package/dist/hooks/post-bash.d.ts +46 -0
- package/dist/hooks/post-bash.js +77 -0
- package/dist/hooks/post-task.d.ts +67 -0
- package/dist/hooks/post-task.js +136 -0
- package/dist/hooks/session-start.d.ts +45 -0
- package/dist/hooks/session-start.js +179 -0
- package/dist/hooks/summary-ast-index.d.ts +28 -0
- package/dist/hooks/summary-ast-index.js +122 -0
- package/dist/hooks/summary-head-tail.d.ts +15 -0
- package/dist/hooks/summary-head-tail.js +78 -0
- package/dist/hooks/summary-pipeline.d.ts +35 -0
- package/dist/hooks/summary-pipeline.js +63 -0
- package/dist/hooks/summary-regex.d.ts +14 -0
- package/dist/hooks/summary-regex.js +130 -0
- package/dist/hooks/summary-types.d.ts +29 -0
- package/dist/hooks/summary-types.js +9 -0
- package/dist/index.d.ts +15 -3
- package/dist/index.js +538 -149
- package/dist/integration/context-mode-detector.d.ts +7 -1
- package/dist/integration/context-mode-detector.js +51 -15
- package/dist/server/tool-definitions.d.ts +149 -0
- package/dist/server/tool-definitions.js +424 -202
- package/dist/server.d.ts +1 -1
- package/dist/server.js +456 -179
- package/dist/templates/agent-builder.d.ts +49 -0
- package/dist/templates/agent-builder.js +104 -0
- package/dist/types.d.ts +38 -4
- package/package.json +4 -2
- package/skills/stats/SKILL.md +13 -2
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5 subtask 5.5 — uninstall tp-* agents.
|
|
3
|
+
*
|
|
4
|
+
* Removes only files in the target scope's `.claude/agents/` that have
|
|
5
|
+
* `token_pilot_body_hash` in their frontmatter. Files without the marker
|
|
6
|
+
* are user-owned and are never touched. Scope is required — no global
|
|
7
|
+
* default — to prevent accidental deletion from the wrong location.
|
|
8
|
+
*
|
|
9
|
+
* Symmetric to install-agents: separation of core (uninstallAgents) and
|
|
10
|
+
* CLI wrapper (handleUninstallAgents) mirrors the Phase 3 bless/unbless
|
|
11
|
+
* split.
|
|
12
|
+
*/
|
|
13
|
+
export type Scope = "user" | "project";
|
|
14
|
+
export interface UninstallOptions {
|
|
15
|
+
scope: Scope;
|
|
16
|
+
projectRoot: string;
|
|
17
|
+
homeDir: string;
|
|
18
|
+
}
|
|
19
|
+
export interface UninstallResult {
|
|
20
|
+
removed: string[];
|
|
21
|
+
skipped: Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
}>;
|
|
25
|
+
targetDir: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function uninstallAgents(opts: UninstallOptions): Promise<UninstallResult>;
|
|
28
|
+
/**
|
|
29
|
+
* CLI entry: `token-pilot uninstall-agents --scope=user|project`.
|
|
30
|
+
* Returns the exit code (0 success, 1 error).
|
|
31
|
+
*/
|
|
32
|
+
export declare function handleUninstallAgents(argv: string[], opts?: {
|
|
33
|
+
homeDir?: string;
|
|
34
|
+
projectRoot?: string;
|
|
35
|
+
}): Promise<number>;
|
|
36
|
+
//# sourceMappingURL=uninstall-agents.d.ts.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5 subtask 5.5 — uninstall tp-* agents.
|
|
3
|
+
*
|
|
4
|
+
* Removes only files in the target scope's `.claude/agents/` that have
|
|
5
|
+
* `token_pilot_body_hash` in their frontmatter. Files without the marker
|
|
6
|
+
* are user-owned and are never touched. Scope is required — no global
|
|
7
|
+
* default — to prevent accidental deletion from the wrong location.
|
|
8
|
+
*
|
|
9
|
+
* Symmetric to install-agents: separation of core (uninstallAgents) and
|
|
10
|
+
* CLI wrapper (handleUninstallAgents) mirrors the Phase 3 bless/unbless
|
|
11
|
+
* split.
|
|
12
|
+
*/
|
|
13
|
+
import { readdir, readFile, unlink } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { parseFrontmatter } from "./agent-frontmatter.js";
|
|
17
|
+
function targetDirFor(opts) {
|
|
18
|
+
const root = opts.scope === "user" ? opts.homeDir : opts.projectRoot;
|
|
19
|
+
return join(root, ".claude", "agents");
|
|
20
|
+
}
|
|
21
|
+
function hasTpHashMarker(meta) {
|
|
22
|
+
const h = meta.token_pilot_body_hash;
|
|
23
|
+
return typeof h === "string" && h.length > 0;
|
|
24
|
+
}
|
|
25
|
+
export async function uninstallAgents(opts) {
|
|
26
|
+
const target = targetDirFor(opts);
|
|
27
|
+
const result = {
|
|
28
|
+
removed: [],
|
|
29
|
+
skipped: [],
|
|
30
|
+
targetDir: target,
|
|
31
|
+
};
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = await readdir(target);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Target dir missing → nothing to do.
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
const tpFiles = entries.filter((f) => f.endsWith(".md") && f.startsWith("tp-"));
|
|
41
|
+
for (const entry of tpFiles) {
|
|
42
|
+
const name = entry.replace(/\.md$/, "");
|
|
43
|
+
const fullPath = join(target, entry);
|
|
44
|
+
let md;
|
|
45
|
+
try {
|
|
46
|
+
md = await readFile(fullPath, "utf-8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
result.skipped.push({ name, reason: "read failed" });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let meta;
|
|
53
|
+
try {
|
|
54
|
+
({ meta } = parseFrontmatter(md));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
result.skipped.push({ name, reason: "malformed frontmatter" });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!hasTpHashMarker(meta)) {
|
|
61
|
+
result.skipped.push({
|
|
62
|
+
name,
|
|
63
|
+
reason: "not installed by token-pilot (no token_pilot_body_hash)",
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
await unlink(fullPath);
|
|
69
|
+
result.removed.push(name);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
result.skipped.push({ name, reason: "delete failed" });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
// ─── CLI wrapper ─────────────────────────────────────────────────────────────
|
|
78
|
+
function parseFlag(argv, key) {
|
|
79
|
+
for (const a of argv) {
|
|
80
|
+
if (a === `--${key}`)
|
|
81
|
+
return "true";
|
|
82
|
+
if (a.startsWith(`--${key}=`))
|
|
83
|
+
return a.slice(key.length + 3);
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* CLI entry: `token-pilot uninstall-agents --scope=user|project`.
|
|
89
|
+
* Returns the exit code (0 success, 1 error).
|
|
90
|
+
*/
|
|
91
|
+
export async function handleUninstallAgents(argv, opts) {
|
|
92
|
+
const scopeArg = parseFlag(argv, "scope");
|
|
93
|
+
if (scopeArg !== "user" && scopeArg !== "project") {
|
|
94
|
+
process.stderr.write("Usage: token-pilot uninstall-agents --scope=user|project\n");
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const result = await uninstallAgents({
|
|
98
|
+
scope: scopeArg,
|
|
99
|
+
projectRoot: opts?.projectRoot ?? process.cwd(),
|
|
100
|
+
homeDir: opts?.homeDir ?? homedir(),
|
|
101
|
+
});
|
|
102
|
+
if (result.removed.length > 0) {
|
|
103
|
+
const plural = result.removed.length === 1 ? "agent" : "agents";
|
|
104
|
+
process.stderr.write(`[token-pilot] Removed ${result.removed.length} ${plural} from ${result.targetDir}.\n`);
|
|
105
|
+
}
|
|
106
|
+
if (result.skipped.length > 0) {
|
|
107
|
+
process.stderr.write(`[token-pilot] Skipped ${result.skipped.length}:\n`);
|
|
108
|
+
for (const s of result.skipped) {
|
|
109
|
+
process.stderr.write(` - ${s.name}: ${s.reason}\n`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (result.removed.length === 0 && result.skipped.length === 0) {
|
|
113
|
+
process.stderr.write(`[token-pilot] No tp-* agents found in ${result.targetDir}.\n`);
|
|
114
|
+
}
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=uninstall-agents.js.map
|
package/dist/config/defaults.js
CHANGED
|
@@ -26,6 +26,9 @@ export const DEFAULT_CONFIG = {
|
|
|
26
26
|
interceptRead: true,
|
|
27
27
|
autoInstall: true,
|
|
28
28
|
denyThreshold: 300,
|
|
29
|
+
mode: "deny-enhanced",
|
|
30
|
+
adaptiveThreshold: false,
|
|
31
|
+
adaptiveBudgetTokens: 100_000,
|
|
29
32
|
},
|
|
30
33
|
context: {
|
|
31
34
|
estimateTokens: true,
|
|
@@ -40,7 +43,7 @@ export const DEFAULT_CONFIG = {
|
|
|
40
43
|
actionableHints: true,
|
|
41
44
|
},
|
|
42
45
|
contextMode: {
|
|
43
|
-
enabled:
|
|
46
|
+
enabled: "auto",
|
|
44
47
|
adviseDelegation: true,
|
|
45
48
|
largeNonCodeThreshold: 200,
|
|
46
49
|
},
|
|
@@ -62,12 +65,15 @@ export const DEFAULT_CONFIG = {
|
|
|
62
65
|
compactionCallThreshold: 15,
|
|
63
66
|
compactionTokenThreshold: 8000,
|
|
64
67
|
},
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
sessionStart: {
|
|
69
|
+
enabled: true,
|
|
70
|
+
showStats: false,
|
|
71
|
+
maxReminderTokens: 250,
|
|
72
|
+
},
|
|
73
|
+
agents: {
|
|
74
|
+
scope: null,
|
|
75
|
+
reminder: true,
|
|
76
|
+
},
|
|
77
|
+
ignore: ["node_modules/**", "dist/**", ".git/**", "*.min.js", "*.map"],
|
|
72
78
|
};
|
|
73
79
|
//# sourceMappingURL=defaults.js.map
|
package/dist/config/loader.d.ts
CHANGED
package/dist/config/loader.js
CHANGED
|
@@ -1,30 +1,124 @@
|
|
|
1
|
-
import { readFile } from
|
|
2
|
-
import { resolve } from
|
|
3
|
-
import { DEFAULT_CONFIG } from
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { DEFAULT_CONFIG } from "./defaults.js";
|
|
4
|
+
const VALID_HOOK_MODES = new Set([
|
|
5
|
+
"off",
|
|
6
|
+
"advisory",
|
|
7
|
+
"deny-enhanced",
|
|
8
|
+
]);
|
|
4
9
|
export async function loadConfig(projectRoot) {
|
|
5
|
-
const configPath = resolve(projectRoot,
|
|
10
|
+
const configPath = resolve(projectRoot, ".token-pilot.json");
|
|
11
|
+
let userConfig = null;
|
|
6
12
|
try {
|
|
7
|
-
const raw = await readFile(configPath,
|
|
8
|
-
|
|
9
|
-
return deepMerge(structuredClone(DEFAULT_CONFIG), userConfig);
|
|
13
|
+
const raw = await readFile(configPath, "utf-8");
|
|
14
|
+
userConfig = JSON.parse(raw);
|
|
10
15
|
}
|
|
11
16
|
catch (err) {
|
|
12
|
-
if (err?.code !==
|
|
17
|
+
if (err?.code !== "ENOENT") {
|
|
13
18
|
console.error(`[token-pilot] Invalid config at ${configPath}: ${err?.message ?? err}. Using defaults.`);
|
|
14
19
|
}
|
|
15
20
|
return structuredClone(DEFAULT_CONFIG);
|
|
16
21
|
}
|
|
22
|
+
// Phase 6 subtask 6.4 — rewrite legacy `mode:"deny"` to `"advisory"`
|
|
23
|
+
// before merge so downstream code (incl. applyHookModeMigration's
|
|
24
|
+
// unknown-mode warning) sees the migrated value.
|
|
25
|
+
await applyLegacyDenyMigration(configPath, userConfig ?? {});
|
|
26
|
+
const merged = deepMerge(structuredClone(DEFAULT_CONFIG), userConfig ?? {});
|
|
27
|
+
applyHookModeMigration(merged, userConfig ?? {});
|
|
28
|
+
applyEnvOverrides(merged);
|
|
29
|
+
return merged;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Env-var overrides that the user can set without editing the config
|
|
33
|
+
* file. Per TP-816 §7.3. Only integer-valued, positive numbers are
|
|
34
|
+
* accepted; malformed values are ignored silently.
|
|
35
|
+
*/
|
|
36
|
+
function applyEnvOverrides(merged) {
|
|
37
|
+
const raw = process.env.TOKEN_PILOT_DENY_THRESHOLD;
|
|
38
|
+
if (raw !== undefined) {
|
|
39
|
+
const n = Number.parseInt(raw, 10);
|
|
40
|
+
if (Number.isFinite(n) && n > 0) {
|
|
41
|
+
merged.hooks.denyThreshold = n;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const adaptive = process.env.TOKEN_PILOT_ADAPTIVE_THRESHOLD;
|
|
45
|
+
if (adaptive !== undefined) {
|
|
46
|
+
merged.hooks.adaptiveThreshold = /^(1|true|yes|on)$/i.test(adaptive.trim());
|
|
47
|
+
}
|
|
48
|
+
const budget = process.env.TOKEN_PILOT_ADAPTIVE_BUDGET;
|
|
49
|
+
if (budget !== undefined) {
|
|
50
|
+
const n = Number.parseInt(budget, 10);
|
|
51
|
+
if (Number.isFinite(n) && n > 0) {
|
|
52
|
+
merged.hooks.adaptiveBudgetTokens = n;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* When a user's config still has `hooks.mode: "deny"` (the removed
|
|
58
|
+
* v0.19 legacy value), rewrite it on disk to `"advisory"` and stamp
|
|
59
|
+
* `hooks.migratedFrom: "deny"` so the stderr notice fires exactly once.
|
|
60
|
+
*
|
|
61
|
+
* Mutates `userConfig` in place so the caller's subsequent merge picks
|
|
62
|
+
* up the new mode. Failures are swallowed — a broken rewrite must not
|
|
63
|
+
* prevent the session from starting.
|
|
64
|
+
*/
|
|
65
|
+
async function applyLegacyDenyMigration(configPath, userConfig) {
|
|
66
|
+
const hooks = (userConfig.hooks ?? {});
|
|
67
|
+
if (hooks.mode !== "deny")
|
|
68
|
+
return;
|
|
69
|
+
if (hooks.migratedFrom === "deny") {
|
|
70
|
+
// Already migrated at some point; user reverted mode manually.
|
|
71
|
+
// Leave their choice alone — downstream unknown-mode path will
|
|
72
|
+
// handle it (falls back to default with a warning).
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
hooks.mode = "advisory";
|
|
76
|
+
hooks.migratedFrom = "deny";
|
|
77
|
+
userConfig.hooks = hooks;
|
|
78
|
+
console.error(`[token-pilot] Config migrated: hooks.mode "deny" is no longer valid in v0.20. ` +
|
|
79
|
+
`Rewriting to "advisory" (strict superset of old behaviour is "deny-enhanced"; ` +
|
|
80
|
+
`switch there manually when ready). Stamped hooks.migratedFrom:"deny" to silence this notice.`);
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(configPath, JSON.stringify(userConfig, null, 2) + "\n");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* ignore — migration is best-effort */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Reconcile the new hooks.mode field with the legacy hooks.enabled boolean.
|
|
90
|
+
* - Explicit user-provided mode wins (after validation).
|
|
91
|
+
* - If user omitted mode but set enabled:false → migrate to mode:"off" with a
|
|
92
|
+
* deprecation notice (preserves v0.19 behaviour for users who actively
|
|
93
|
+
* turned the hook off).
|
|
94
|
+
* - Unknown mode values fall back to the default with a warning.
|
|
95
|
+
*/
|
|
96
|
+
function applyHookModeMigration(merged, userConfig) {
|
|
97
|
+
const userHooks = (userConfig.hooks ?? {});
|
|
98
|
+
const userProvidedMode = typeof userHooks.mode === "string";
|
|
99
|
+
const userSetEnabledFalse = userHooks.enabled === false;
|
|
100
|
+
if (userProvidedMode && !VALID_HOOK_MODES.has(merged.hooks.mode)) {
|
|
101
|
+
console.error(`[token-pilot] Unknown hooks.mode "${merged.hooks.mode}". ` +
|
|
102
|
+
`Valid values: off, advisory, deny-enhanced. Falling back to default "${DEFAULT_CONFIG.hooks.mode}".`);
|
|
103
|
+
merged.hooks.mode = DEFAULT_CONFIG.hooks.mode;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!userProvidedMode && userSetEnabledFalse) {
|
|
107
|
+
console.error(`[token-pilot] hooks.enabled:false is deprecated — migrated to hooks.mode:"off". ` +
|
|
108
|
+
`Update your .token-pilot.json to use hooks.mode explicitly.`);
|
|
109
|
+
merged.hooks.mode = "off";
|
|
110
|
+
}
|
|
17
111
|
}
|
|
18
112
|
function deepMerge(target, source) {
|
|
19
113
|
const result = { ...target };
|
|
20
114
|
for (const key of Object.keys(source)) {
|
|
21
|
-
if (key ===
|
|
115
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype")
|
|
22
116
|
continue;
|
|
23
117
|
if (source[key] &&
|
|
24
|
-
typeof source[key] ===
|
|
118
|
+
typeof source[key] === "object" &&
|
|
25
119
|
!Array.isArray(source[key]) &&
|
|
26
120
|
target[key] &&
|
|
27
|
-
typeof target[key] ===
|
|
121
|
+
typeof target[key] === "object") {
|
|
28
122
|
result[key] = deepMerge(target[key], source[key]);
|
|
29
123
|
}
|
|
30
124
|
else {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContextEntry, LoadedRegion, SymbolInfo } from
|
|
1
|
+
import type { ContextEntry, LoadedRegion, SymbolInfo } from "../types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Advisory Context Registry.
|
|
4
4
|
* Tracks what was sent to the LLM but never blocks re-sends.
|
|
@@ -45,5 +45,20 @@ export declare class ContextRegistry {
|
|
|
45
45
|
/** Get the timestamp when a file was last loaded into context. */
|
|
46
46
|
getLoadedAt(path: string): number | undefined;
|
|
47
47
|
invalidateByGitDiff(changedFiles: string[]): void;
|
|
48
|
+
/**
|
|
49
|
+
* Serialize registry state to a plain object (TP-69m persistence).
|
|
50
|
+
* Sets inside `entries[*].loaded[*]` lack `Set` fields; only `contentHash`
|
|
51
|
+
* etc. are simple scalars, so JSON round-trip works.
|
|
52
|
+
*/
|
|
53
|
+
toSnapshot(): {
|
|
54
|
+
sessionStart: number;
|
|
55
|
+
entries: ContextEntry[];
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Rehydrate registry state previously produced by `toSnapshot()`. Silent
|
|
59
|
+
* on malformed input — a broken snapshot file should degrade to an empty
|
|
60
|
+
* registry, not crash the MCP server.
|
|
61
|
+
*/
|
|
62
|
+
loadSnapshot(snap: unknown): void;
|
|
48
63
|
}
|
|
49
64
|
//# sourceMappingURL=context-registry.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatDuration } from
|
|
1
|
+
import { formatDuration } from "./format-duration.js";
|
|
2
2
|
/**
|
|
3
3
|
* Advisory Context Registry.
|
|
4
4
|
* Tracks what was sent to the LLM but never blocks re-sends.
|
|
@@ -11,7 +11,7 @@ export class ContextRegistry {
|
|
|
11
11
|
const existing = this.entries.get(path);
|
|
12
12
|
if (existing) {
|
|
13
13
|
// Replace region of same type/symbol, add new ones
|
|
14
|
-
const idx = existing.loaded.findIndex(r => r.type === region.type && r.symbolName === region.symbolName);
|
|
14
|
+
const idx = existing.loaded.findIndex((r) => r.type === region.type && r.symbolName === region.symbolName);
|
|
15
15
|
if (idx >= 0) {
|
|
16
16
|
existing.loaded[idx] = region;
|
|
17
17
|
}
|
|
@@ -25,7 +25,7 @@ export class ContextRegistry {
|
|
|
25
25
|
this.entries.set(path, {
|
|
26
26
|
path,
|
|
27
27
|
loaded: [region],
|
|
28
|
-
contentHash:
|
|
28
|
+
contentHash: "",
|
|
29
29
|
tokenEstimate: region.tokens,
|
|
30
30
|
loadedAt: Date.now(),
|
|
31
31
|
});
|
|
@@ -45,7 +45,7 @@ export class ContextRegistry {
|
|
|
45
45
|
const entry = this.entries.get(path);
|
|
46
46
|
if (!entry)
|
|
47
47
|
return false;
|
|
48
|
-
return entry.loaded.some(r => r.symbolName === symbolName);
|
|
48
|
+
return entry.loaded.some((r) => r.symbolName === symbolName);
|
|
49
49
|
}
|
|
50
50
|
/** Check if any region of a file has been loaded into context. */
|
|
51
51
|
hasAnyLoaded(path) {
|
|
@@ -68,14 +68,14 @@ export class ContextRegistry {
|
|
|
68
68
|
compactReminder(path, symbols) {
|
|
69
69
|
const entry = this.entries.get(path);
|
|
70
70
|
if (!entry)
|
|
71
|
-
return
|
|
71
|
+
return "";
|
|
72
72
|
const elapsed = formatDuration(Date.now() - entry.loadedAt);
|
|
73
73
|
const lines = [
|
|
74
74
|
`REMINDER: ${path} (previously loaded ${elapsed} ago, unchanged)`,
|
|
75
|
-
|
|
75
|
+
"",
|
|
76
76
|
];
|
|
77
77
|
for (const region of entry.loaded) {
|
|
78
|
-
if (region.type ===
|
|
78
|
+
if (region.type === "structure") {
|
|
79
79
|
lines.push(` Structure loaded (${region.tokens} tokens)`);
|
|
80
80
|
// Add brief symbol list
|
|
81
81
|
for (const sym of symbols.slice(0, 5)) {
|
|
@@ -85,23 +85,23 @@ export class ContextRegistry {
|
|
|
85
85
|
lines.push(` ... (${symbols.length - 5} more symbols)`);
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
-
else if (region.type ===
|
|
88
|
+
else if (region.type === "symbol" && region.symbolName) {
|
|
89
89
|
lines.push(` ${region.symbolName} [L${region.startLine}-${region.endLine}] (${region.tokens} tokens)`);
|
|
90
90
|
}
|
|
91
|
-
else if (region.type ===
|
|
91
|
+
else if (region.type === "full") {
|
|
92
92
|
lines.push(` Full file loaded (${region.tokens} tokens)`);
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
-
lines.push(
|
|
96
|
-
lines.push(
|
|
97
|
-
return lines.join(
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push("HINT: File unchanged since last read. Use read_symbol() to reload specific parts, or read_diff() to see changes.");
|
|
97
|
+
return lines.join("\n");
|
|
98
98
|
}
|
|
99
99
|
/** Check if file was loaded in full (type='full' region exists). */
|
|
100
100
|
isFullyLoaded(path) {
|
|
101
101
|
const entry = this.entries.get(path);
|
|
102
102
|
if (!entry)
|
|
103
103
|
return false;
|
|
104
|
-
return entry.loaded.some(r => r.type ===
|
|
104
|
+
return entry.loaded.some((r) => r.type === "full");
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
107
|
* Generate a compact dedup reminder for read_symbol.
|
|
@@ -110,24 +110,26 @@ export class ContextRegistry {
|
|
|
110
110
|
symbolReminder(path, symbolName) {
|
|
111
111
|
const entry = this.entries.get(path);
|
|
112
112
|
if (!entry)
|
|
113
|
-
return
|
|
113
|
+
return "";
|
|
114
114
|
const elapsed = formatDuration(Date.now() - entry.loadedAt);
|
|
115
|
-
const symbolRegion = entry.loaded.find(r => r.symbolName === symbolName);
|
|
116
|
-
const fullRegion = entry.loaded.find(r => r.type ===
|
|
115
|
+
const symbolRegion = entry.loaded.find((r) => r.symbolName === symbolName);
|
|
116
|
+
const fullRegion = entry.loaded.find((r) => r.type === "full");
|
|
117
117
|
if (fullRegion) {
|
|
118
|
-
const loc = symbolRegion
|
|
118
|
+
const loc = symbolRegion
|
|
119
|
+
? ` Symbol at [L${symbolRegion.startLine}-${symbolRegion.endLine}].`
|
|
120
|
+
: "";
|
|
119
121
|
return [
|
|
120
122
|
`DEDUP: "${symbolName}" in ${path} — full file already in context (loaded ${elapsed} ago, ${fullRegion.tokens} tokens, unchanged).${loc}`,
|
|
121
|
-
|
|
122
|
-
].join(
|
|
123
|
+
"HINT: File unchanged. No need to re-read. Use read_for_edit() if you need exact code for editing.",
|
|
124
|
+
].join("\n");
|
|
123
125
|
}
|
|
124
126
|
if (symbolRegion) {
|
|
125
127
|
return [
|
|
126
128
|
`DEDUP: "${symbolName}" in ${path} — already loaded ${elapsed} ago [L${symbolRegion.startLine}-${symbolRegion.endLine}] (${symbolRegion.tokens} tokens, unchanged).`,
|
|
127
|
-
|
|
128
|
-
].join(
|
|
129
|
+
"HINT: Symbol unchanged since last read. No need to re-read.",
|
|
130
|
+
].join("\n");
|
|
129
131
|
}
|
|
130
|
-
return
|
|
132
|
+
return "";
|
|
131
133
|
}
|
|
132
134
|
/**
|
|
133
135
|
* Generate a compact dedup reminder for read_range.
|
|
@@ -136,21 +138,21 @@ export class ContextRegistry {
|
|
|
136
138
|
rangeReminder(path, startLine, endLine) {
|
|
137
139
|
const entry = this.entries.get(path);
|
|
138
140
|
if (!entry)
|
|
139
|
-
return
|
|
140
|
-
const fullRegion = entry.loaded.find(r => r.type ===
|
|
141
|
+
return "";
|
|
142
|
+
const fullRegion = entry.loaded.find((r) => r.type === "full");
|
|
141
143
|
if (!fullRegion)
|
|
142
|
-
return
|
|
144
|
+
return "";
|
|
143
145
|
const elapsed = formatDuration(Date.now() - entry.loadedAt);
|
|
144
146
|
return [
|
|
145
147
|
`DEDUP: ${path} [L${startLine}-${endLine}] — full file already in context (loaded ${elapsed} ago, ${fullRegion.tokens} tokens, unchanged).`,
|
|
146
|
-
|
|
147
|
-
].join(
|
|
148
|
+
"HINT: File unchanged. No need to re-read. Use read_for_edit() if you need exact code for editing.",
|
|
149
|
+
].join("\n");
|
|
148
150
|
}
|
|
149
151
|
forget(path, symbolName) {
|
|
150
152
|
if (symbolName) {
|
|
151
153
|
const entry = this.entries.get(path);
|
|
152
154
|
if (entry) {
|
|
153
|
-
entry.loaded = entry.loaded.filter(r => r.symbolName !== symbolName);
|
|
155
|
+
entry.loaded = entry.loaded.filter((r) => r.symbolName !== symbolName);
|
|
154
156
|
if (entry.loaded.length === 0) {
|
|
155
157
|
this.entries.delete(path);
|
|
156
158
|
}
|
|
@@ -200,5 +202,35 @@ export class ContextRegistry {
|
|
|
200
202
|
this.entries.delete(file);
|
|
201
203
|
}
|
|
202
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Serialize registry state to a plain object (TP-69m persistence).
|
|
207
|
+
* Sets inside `entries[*].loaded[*]` lack `Set` fields; only `contentHash`
|
|
208
|
+
* etc. are simple scalars, so JSON round-trip works.
|
|
209
|
+
*/
|
|
210
|
+
toSnapshot() {
|
|
211
|
+
return {
|
|
212
|
+
sessionStart: this.sessionStart,
|
|
213
|
+
entries: Array.from(this.entries.values()),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Rehydrate registry state previously produced by `toSnapshot()`. Silent
|
|
218
|
+
* on malformed input — a broken snapshot file should degrade to an empty
|
|
219
|
+
* registry, not crash the MCP server.
|
|
220
|
+
*/
|
|
221
|
+
loadSnapshot(snap) {
|
|
222
|
+
if (!snap || typeof snap !== "object")
|
|
223
|
+
return;
|
|
224
|
+
const s = snap;
|
|
225
|
+
if (typeof s.sessionStart === "number")
|
|
226
|
+
this.sessionStart = s.sessionStart;
|
|
227
|
+
if (Array.isArray(s.entries)) {
|
|
228
|
+
for (const e of s.entries) {
|
|
229
|
+
if (e && typeof e.path === "string" && Array.isArray(e.loaded)) {
|
|
230
|
+
this.entries.set(e.path, e);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
203
235
|
}
|
|
204
236
|
//# sourceMappingURL=context-registry.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 6 subtasks 6.1 + 6.2 — hook-events JSONL log.
|
|
3
|
+
*
|
|
4
|
+
* Writes to `<projectRoot>/.token-pilot/hook-events.jsonl` with the
|
|
5
|
+
* schema specified in TP-c2a acceptance:
|
|
6
|
+
*
|
|
7
|
+
* { ts, session_id, agent_type, agent_id, event, file, lines,
|
|
8
|
+
* estTokens, summaryTokens, savedTokens }
|
|
9
|
+
*
|
|
10
|
+
* Rotation: when the current file grows past `ROTATION_THRESHOLD_BYTES`
|
|
11
|
+
* (10 MB), it is renamed to `hook-events.<unix-ms>.jsonl` and a new
|
|
12
|
+
* empty file begins.
|
|
13
|
+
*
|
|
14
|
+
* Retention: `applyRetention` deletes rotated files older than
|
|
15
|
+
* `RETENTION_MAX_AGE_DAYS` (30 days) and trims the directory down to
|
|
16
|
+
* `RETENTION_MAX_TOTAL_BYTES` (100 MB) by removing the oldest archives
|
|
17
|
+
* first. The current file is never deleted.
|
|
18
|
+
*
|
|
19
|
+
* Legacy coexistence: the old `.token-pilot/hook-denied.jsonl` is left
|
|
20
|
+
* in place — this module writes only to the new file.
|
|
21
|
+
*/
|
|
22
|
+
export declare const ROTATION_THRESHOLD_BYTES = 10000000;
|
|
23
|
+
export declare const RETENTION_MAX_AGE_DAYS = 30;
|
|
24
|
+
export declare const RETENTION_MAX_TOTAL_BYTES = 100000000;
|
|
25
|
+
export interface HookEvent {
|
|
26
|
+
ts: number;
|
|
27
|
+
session_id: string;
|
|
28
|
+
/** null for top-level session; agent_type string inside a subagent. */
|
|
29
|
+
agent_type: string | null;
|
|
30
|
+
agent_id: string | null;
|
|
31
|
+
event: "denied" | "allowed" | "bypass" | "pass-through" | string;
|
|
32
|
+
file: string;
|
|
33
|
+
lines: number;
|
|
34
|
+
estTokens: number;
|
|
35
|
+
/** Tokens delivered back to the agent as the summary; 0 for allow/bypass. */
|
|
36
|
+
summaryTokens: number;
|
|
37
|
+
/** estTokens - summaryTokens; 0 for allow/bypass. */
|
|
38
|
+
savedTokens: number;
|
|
39
|
+
}
|
|
40
|
+
export declare function eventLogDir(projectRoot: string): string;
|
|
41
|
+
export declare function currentLogPath(projectRoot: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Decide whether the current log file has grown past the rotation
|
|
44
|
+
* threshold and should be archived before the next append.
|
|
45
|
+
*/
|
|
46
|
+
export declare function shouldRotate(stat: {
|
|
47
|
+
size: number;
|
|
48
|
+
}, thresholdBytes?: number): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Given the full list of archive files with their mtime + size, return
|
|
51
|
+
* the subset whose paths should be deleted to satisfy:
|
|
52
|
+
* (a) maxAgeDays — delete anything older
|
|
53
|
+
* (b) maxTotalBytes — delete oldest first until total fits
|
|
54
|
+
*
|
|
55
|
+
* `now` is passed in to keep the function deterministic for tests.
|
|
56
|
+
*/
|
|
57
|
+
export declare function retentionDeletions(files: Array<{
|
|
58
|
+
path: string;
|
|
59
|
+
mtime: Date;
|
|
60
|
+
size: number;
|
|
61
|
+
}>, now: Date, maxAgeDays?: number, maxTotalBytes?: number): string[];
|
|
62
|
+
/**
|
|
63
|
+
* Append one event to the current log file. Rotates first if the
|
|
64
|
+
* current file has reached the threshold. Never throws — a failure
|
|
65
|
+
* here must not break hook dispatch.
|
|
66
|
+
*/
|
|
67
|
+
export declare function appendEvent(projectRoot: string, event: HookEvent): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* Read all events from the current log file. Malformed JSONL lines are
|
|
70
|
+
* skipped silently (a corrupted line should not poison the whole
|
|
71
|
+
* dataset). Returns [] if the file is missing.
|
|
72
|
+
*/
|
|
73
|
+
export declare function loadEvents(projectRoot: string): Promise<HookEvent[]>;
|
|
74
|
+
/**
|
|
75
|
+
* Apply age + size retention. Safe to call on startup; no-op when the
|
|
76
|
+
* directory does not exist.
|
|
77
|
+
*/
|
|
78
|
+
export declare function applyRetention(projectRoot: string, now?: Date): Promise<void>;
|
|
79
|
+
//# sourceMappingURL=event-log.d.ts.map
|