@pugi/cli 0.1.0-beta.17 → 0.1.0-beta.19
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/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/engine/native-pugi.js +20 -0
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +251 -49
- package/dist/core/file-cache.js +113 -1
- package/dist/core/mcp/client.js +66 -6
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/session.js +261 -9
- package/dist/core/repl/slash-commands.js +67 -4
- package/dist/runtime/cli.js +153 -58
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/mcp.js +290 -3
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +18 -4
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/file-tools.js +57 -14
- package/dist/tools/registry.js +7 -0
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +68 -7
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/status-table.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-workspace permission-mode session state — Leak L6.
|
|
3
|
+
*
|
|
4
|
+
* State lives in `.pugi/session.json` under the workspace root. The
|
|
5
|
+
* file is read on first `getCurrentMode()` call (cached for the
|
|
6
|
+
* process lifetime) and written atomically via tmp+rename on
|
|
7
|
+
* `setCurrentMode()` so a kill mid-write does not corrupt the JSON.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order for the effective mode on a fresh process:
|
|
10
|
+
* 1. CLI flag (`pugi --mode plan`) — passed via `resolveMode` arg;
|
|
11
|
+
* not read from disk here.
|
|
12
|
+
* 2. Workspace session state — `<root>/.pugi/session.json` field
|
|
13
|
+
* `permissionMode`.
|
|
14
|
+
* 3. Global config — `~/.pugi/config.json` field
|
|
15
|
+
* `defaultPermissionMode`.
|
|
16
|
+
* 4. Hard default `ask`.
|
|
17
|
+
*
|
|
18
|
+
* This module owns layers 2 + 3. The CLI arg parser owns layer 1; both
|
|
19
|
+
* funnel into `resolveMode()` which performs the merge.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { dirname, resolve } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
import { DEFAULT_PERMISSION_MODE, isPermissionMode, parsePermissionMode, } from './mode.js';
|
|
26
|
+
const permissionModeEnum = z.enum(['plan', 'ask', 'allow', 'bypass']);
|
|
27
|
+
const sessionStateSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
permissionMode: permissionModeEnum.optional(),
|
|
30
|
+
})
|
|
31
|
+
.partial()
|
|
32
|
+
.passthrough();
|
|
33
|
+
const globalConfigSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
defaultPermissionMode: permissionModeEnum.optional(),
|
|
36
|
+
})
|
|
37
|
+
.partial()
|
|
38
|
+
.passthrough();
|
|
39
|
+
const SESSION_FILE = '.pugi/session.json';
|
|
40
|
+
/**
|
|
41
|
+
* Return the path to the workspace session-state file.
|
|
42
|
+
*/
|
|
43
|
+
export function sessionStatePath(workspaceRoot) {
|
|
44
|
+
return resolve(workspaceRoot, SESSION_FILE);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Return the path to the user-global config file. Uses HOME env when
|
|
48
|
+
* present (test fixtures, CI) so we never accidentally hit the real
|
|
49
|
+
* user-global file in spec runs.
|
|
50
|
+
*/
|
|
51
|
+
export function globalConfigPath(homeDir = homedir()) {
|
|
52
|
+
return resolve(homeDir, '.pugi/config.json');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Read the workspace's saved permission mode. Returns null when the
|
|
56
|
+
* file is absent OR the field is unset; the caller layers in CLI + env
|
|
57
|
+
* + global config defaults to produce the effective mode.
|
|
58
|
+
*
|
|
59
|
+
* Never throws on JSON parse / schema errors — a malformed session
|
|
60
|
+
* file should not break the gate. The defensive `try/catch` returns
|
|
61
|
+
* null and lets the caller fall through to the next layer.
|
|
62
|
+
*/
|
|
63
|
+
export function getCurrentMode(workspaceRoot) {
|
|
64
|
+
const path = sessionStatePath(workspaceRoot);
|
|
65
|
+
if (!existsSync(path))
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
const raw = readFileSync(path, 'utf8');
|
|
69
|
+
const parsed = sessionStateSchema.parse(JSON.parse(raw));
|
|
70
|
+
return isPermissionMode(parsed.permissionMode) ? parsed.permissionMode : null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Persist the workspace's permission mode. Creates the `.pugi/` dir
|
|
78
|
+
* when missing; preserves any unrelated keys in the file (passthrough
|
|
79
|
+
* schema). Atomic tmp+rename so a kill mid-write does not corrupt the
|
|
80
|
+
* JSON.
|
|
81
|
+
*/
|
|
82
|
+
export function setCurrentMode(workspaceRoot, mode) {
|
|
83
|
+
const path = sessionStatePath(workspaceRoot);
|
|
84
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
+
const existing = existsSync(path)
|
|
86
|
+
? safeParseObject(readFileSync(path, 'utf8'))
|
|
87
|
+
: {};
|
|
88
|
+
const next = { ...existing, permissionMode: mode };
|
|
89
|
+
const tmpPath = `${path}.tmp`;
|
|
90
|
+
writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
91
|
+
renameSync(tmpPath, path);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read `~/.pugi/config.json::defaultPermissionMode`. Returns null when
|
|
95
|
+
* the file is absent / the field is unset; same defensive behaviour
|
|
96
|
+
* as `getCurrentMode` — a malformed global config never breaks the gate.
|
|
97
|
+
*/
|
|
98
|
+
export function getGlobalDefaultMode(homeDir = homedir()) {
|
|
99
|
+
const path = globalConfigPath(homeDir);
|
|
100
|
+
if (!existsSync(path))
|
|
101
|
+
return null;
|
|
102
|
+
try {
|
|
103
|
+
const raw = readFileSync(path, 'utf8');
|
|
104
|
+
const parsed = globalConfigSchema.parse(JSON.parse(raw));
|
|
105
|
+
return isPermissionMode(parsed.defaultPermissionMode) ? parsed.defaultPermissionMode : null;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Persist `~/.pugi/config.json::defaultPermissionMode`. Used by the
|
|
113
|
+
* `/permissions <mode> --persist` flow so a future fresh session
|
|
114
|
+
* defaults to the same mode without an explicit `--mode` flag.
|
|
115
|
+
*/
|
|
116
|
+
export function setGlobalDefaultMode(mode, homeDir = homedir()) {
|
|
117
|
+
const path = globalConfigPath(homeDir);
|
|
118
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
119
|
+
const existing = existsSync(path)
|
|
120
|
+
? safeParseObject(readFileSync(path, 'utf8'))
|
|
121
|
+
: {};
|
|
122
|
+
const next = { ...existing, defaultPermissionMode: mode };
|
|
123
|
+
const tmpPath = `${path}.tmp`;
|
|
124
|
+
writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
125
|
+
renameSync(tmpPath, path);
|
|
126
|
+
}
|
|
127
|
+
export function resolveMode(options) {
|
|
128
|
+
if (options.cliFlag) {
|
|
129
|
+
const flag = parsePermissionMode(options.cliFlag);
|
|
130
|
+
if (flag)
|
|
131
|
+
return flag;
|
|
132
|
+
}
|
|
133
|
+
const workspace = getCurrentMode(options.workspaceRoot);
|
|
134
|
+
if (workspace)
|
|
135
|
+
return workspace;
|
|
136
|
+
const global = getGlobalDefaultMode(options.homeDir);
|
|
137
|
+
if (global)
|
|
138
|
+
return global;
|
|
139
|
+
return DEFAULT_PERMISSION_MODE;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Defensive helper — parse JSON to an object; non-object payload (top-
|
|
143
|
+
* level array, primitive) collapses to an empty object so the merge
|
|
144
|
+
* doesn't surface a TypeError. The `setCurrentMode` / `setGlobalDefaultMode`
|
|
145
|
+
* helpers only write objects, so a non-object existing file is corrupted
|
|
146
|
+
* and we explicitly reset it rather than appending into a non-object.
|
|
147
|
+
*/
|
|
148
|
+
function safeParseObject(raw) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(raw);
|
|
151
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
152
|
+
return parsed;
|
|
153
|
+
}
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool side-effect classification — Leak L6.
|
|
3
|
+
*
|
|
4
|
+
* Three classes drive the canonical 4-mode permission gate:
|
|
5
|
+
*
|
|
6
|
+
* - `read` — observe-only. Plan mode allows; ask still prompts;
|
|
7
|
+
* allow + bypass execute silently. Examples: read,
|
|
8
|
+
* grep, glob, web_fetch, web_search, skills_list.
|
|
9
|
+
* - `write` — mutates workspace, journal, or operator screen with
|
|
10
|
+
* visible side effects. Plan mode refuses; ask prompts;
|
|
11
|
+
* allow + bypass execute. Examples: write, edit, bash,
|
|
12
|
+
* multi_edit, task_*. `ask_user_question` is also
|
|
13
|
+
* classed as `write` because it interrupts the
|
|
14
|
+
* dispatcher's flow control and demands operator
|
|
15
|
+
* attention — plan mode should not prompt operators.
|
|
16
|
+
* - `dispatch` — spawns a child subagent or off-tree task. Plan mode
|
|
17
|
+
* refuses (a write-capable child violates plan-mode's
|
|
18
|
+
* read-only contract); ask prompts; allow + bypass
|
|
19
|
+
* execute. Example: `agent`.
|
|
20
|
+
*
|
|
21
|
+
* Unknown tool names default to `write` — deny-first safety. A stale
|
|
22
|
+
* schema entry that the gate has not been told about should not silently
|
|
23
|
+
* pass in plan mode just because the gate doesn't recognise it.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Closed map of every built-in tool name -> side-effect class. The
|
|
27
|
+
* source of truth for the four standard modes; mirrored against the
|
|
28
|
+
* `WIRED_TOOLS` set in `core/engine/tool-bridge.ts` so an unrecognised
|
|
29
|
+
* tool surfaces as the safe deny-first `write` default.
|
|
30
|
+
*
|
|
31
|
+
* MCP tools follow the `mcp__<server>__<tool>` namespace and are
|
|
32
|
+
* uniformly classed via `getToolClass` because per-tool annotations are
|
|
33
|
+
* not yet a part of the MCP spec — treating them as `write` is the
|
|
34
|
+
* conservative default until server-side metadata is trustworthy.
|
|
35
|
+
*/
|
|
36
|
+
const BUILT_IN_TOOL_CLASSES = Object.freeze({
|
|
37
|
+
// Read-only observations.
|
|
38
|
+
read: 'read',
|
|
39
|
+
grep: 'read',
|
|
40
|
+
glob: 'read',
|
|
41
|
+
ls: 'read',
|
|
42
|
+
search: 'read',
|
|
43
|
+
web_fetch: 'read',
|
|
44
|
+
web_search: 'read',
|
|
45
|
+
file_cache_check: 'read',
|
|
46
|
+
skills_list: 'read',
|
|
47
|
+
skill: 'read',
|
|
48
|
+
task_get: 'read',
|
|
49
|
+
task_list: 'read',
|
|
50
|
+
// Mutating actions.
|
|
51
|
+
write: 'write',
|
|
52
|
+
edit: 'write',
|
|
53
|
+
multi_edit: 'write',
|
|
54
|
+
bash: 'write',
|
|
55
|
+
task_create: 'write',
|
|
56
|
+
task_update: 'write',
|
|
57
|
+
todo_write: 'write',
|
|
58
|
+
// `ask_user_question` halts the loop and demands operator attention.
|
|
59
|
+
// Plan mode should not interrupt — class as write so the gate refuses
|
|
60
|
+
// it in plan mode but ask + allow + bypass execute normally.
|
|
61
|
+
ask_user_question: 'write',
|
|
62
|
+
// Dispatch — spawn a child agent. Refused in plan mode regardless of
|
|
63
|
+
// the child's role tier (the engine adapter applies role-based
|
|
64
|
+
// capability filtering, but the gate refuses dispatch up front so a
|
|
65
|
+
// plan-mode session cannot leak a writeable child).
|
|
66
|
+
agent: 'dispatch',
|
|
67
|
+
pugi_delegate: 'dispatch',
|
|
68
|
+
sub_agent_spawn: 'dispatch',
|
|
69
|
+
});
|
|
70
|
+
const MCP_TOOL_PREFIX = 'mcp__';
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the class for a tool name. Unknown names default to `write`
|
|
73
|
+
* (deny-first). MCP tools (any name prefixed with `mcp__`) default to
|
|
74
|
+
* `write` for the same conservative reason — the MCP spec lacks
|
|
75
|
+
* per-tool annotations today.
|
|
76
|
+
*/
|
|
77
|
+
export function getToolClass(toolName) {
|
|
78
|
+
const builtIn = BUILT_IN_TOOL_CLASSES[toolName];
|
|
79
|
+
if (builtIn)
|
|
80
|
+
return builtIn;
|
|
81
|
+
if (toolName.startsWith(MCP_TOOL_PREFIX))
|
|
82
|
+
return 'write';
|
|
83
|
+
return 'write';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Expose the built-in class map for diagnostic surfaces (`pugi doctor`,
|
|
87
|
+
* test fixtures). Caller MUST NOT mutate — the object is already frozen
|
|
88
|
+
* so any attempt throws in strict mode.
|
|
89
|
+
*/
|
|
90
|
+
export function listBuiltInToolClasses() {
|
|
91
|
+
return BUILT_IN_TOOL_CLASSES;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=tool-class.js.map
|
|
@@ -34,6 +34,9 @@ import { parseSlashCommand } from './slash-commands.js';
|
|
|
34
34
|
import { webFetchTool } from '../../tools/web-fetch.js';
|
|
35
35
|
import { loadSettings } from '../settings.js';
|
|
36
36
|
import { getJobRegistry } from '../jobs/registry.js';
|
|
37
|
+
import { applyCompactMask } from '../compact/buffer-rewriter.js';
|
|
38
|
+
import { evaluateAutoCompact } from '../compact/auto-trigger.js';
|
|
39
|
+
import { estimateTokensInMany } from '../compact/token-counter.js';
|
|
37
40
|
import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
|
|
38
41
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
39
42
|
import { resolve as resolvePath } from 'node:path';
|
|
@@ -638,7 +641,7 @@ export class ReplSession {
|
|
|
638
641
|
return verdict;
|
|
639
642
|
}
|
|
640
643
|
case 'status': {
|
|
641
|
-
this.dispatchStatus();
|
|
644
|
+
await this.dispatchStatus();
|
|
642
645
|
return verdict;
|
|
643
646
|
}
|
|
644
647
|
case 'consensus': {
|
|
@@ -765,12 +768,123 @@ export class ReplSession {
|
|
|
765
768
|
}
|
|
766
769
|
return verdict;
|
|
767
770
|
}
|
|
771
|
+
case 'doctor': {
|
|
772
|
+
// L17 (2026-05-27): run the doctor probe sweep inline. We
|
|
773
|
+
// dynamic-import the runtime/commands/doctor module so the
|
|
774
|
+
// slash dispatcher does not pull the diagnostics graph
|
|
775
|
+
// (execFileSync + fs probes) into every keystroke. The
|
|
776
|
+
// module's output is captured into local lines so we can
|
|
777
|
+
// render it as system entries in the conversation pane;
|
|
778
|
+
// an Ink-rendered table inside the REPL frame is a follow-up.
|
|
779
|
+
try {
|
|
780
|
+
const { runDoctorCommand, defaultHome } = await import('../../runtime/commands/doctor.js');
|
|
781
|
+
const lines = [];
|
|
782
|
+
await runDoctorCommand({
|
|
783
|
+
cwd: process.cwd(),
|
|
784
|
+
home: defaultHome(),
|
|
785
|
+
env: process.env,
|
|
786
|
+
json: false,
|
|
787
|
+
writeOutput: (_payload, text) => {
|
|
788
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
789
|
+
if (trimmed.length > 0)
|
|
790
|
+
lines.push(trimmed);
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
for (const line of lines)
|
|
794
|
+
this.appendSystemLine(line);
|
|
795
|
+
if (lines.length === 0) {
|
|
796
|
+
this.appendSystemLine('/doctor: no output.');
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
801
|
+
this.appendSystemLine(`/doctor failed: ${message}`);
|
|
802
|
+
}
|
|
803
|
+
return verdict;
|
|
804
|
+
}
|
|
805
|
+
case 'permissions': {
|
|
806
|
+
// Leak L6: handle the `/permissions [mode] [--persist]` flow.
|
|
807
|
+
// The session module forwards to the runtime helper so the
|
|
808
|
+
// workspace + global-config writes share one code path with
|
|
809
|
+
// the CLI's top-level `--mode` resolution. The dynamic import
|
|
810
|
+
// keeps the dispatcher free of a session.ts -> runtime/cli.ts
|
|
811
|
+
// cycle.
|
|
812
|
+
try {
|
|
813
|
+
const { runPermissionsCommand } = await import('../../runtime/commands/permissions.js');
|
|
814
|
+
const lines = [];
|
|
815
|
+
await runPermissionsCommand(verdict, {
|
|
816
|
+
workspaceRoot: process.cwd(),
|
|
817
|
+
writeOutput: (line) => {
|
|
818
|
+
const trimmed = line.replace(/\n+$/u, '');
|
|
819
|
+
if (trimmed.length > 0)
|
|
820
|
+
lines.push(trimmed);
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
for (const line of lines)
|
|
824
|
+
this.appendSystemLine(line);
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
828
|
+
this.appendSystemLine(`/permissions failed: ${message}`);
|
|
829
|
+
}
|
|
830
|
+
return verdict;
|
|
831
|
+
}
|
|
832
|
+
case 'compact': {
|
|
833
|
+
// Leak L8 (2026-05-27): /compact summarises older turns and
|
|
834
|
+
// appends a boundary marker. We forward to the same runner the
|
|
835
|
+
// top-level `pugi compact` command uses so the surface stays
|
|
836
|
+
// single-sourced. The session module owns the in-memory
|
|
837
|
+
// transcript echo (system line + banner row) so the operator
|
|
838
|
+
// sees the marker land without a fresh REPL bootstrap.
|
|
839
|
+
await this.dispatchCompact('manual');
|
|
840
|
+
return verdict;
|
|
841
|
+
}
|
|
768
842
|
case 'stub': {
|
|
769
843
|
this.appendSystemLine(verdict.message);
|
|
770
844
|
return verdict;
|
|
771
845
|
}
|
|
772
846
|
}
|
|
773
847
|
}
|
|
848
|
+
/**
|
|
849
|
+
* Leak L8 (2026-05-27): drive the `/compact` flow from inside the
|
|
850
|
+
* REPL. Reuses the standalone runner so the wire shape + reason
|
|
851
|
+
* codes stay single-sourced. The result is echoed into the
|
|
852
|
+
* transcript as a system line; on success the operator sees the
|
|
853
|
+
* banner sentinel on next render.
|
|
854
|
+
*
|
|
855
|
+
* `trigger='manual'` for explicit `/compact` invocations;
|
|
856
|
+
* `trigger='auto'` for the threshold gate. The runner records the
|
|
857
|
+
* trigger in the marker payload so the banner can distinguish them.
|
|
858
|
+
*/
|
|
859
|
+
async dispatchCompact(trigger) {
|
|
860
|
+
if (!this.store || !this.localSessionId) {
|
|
861
|
+
this.appendSystemLine('Local session store is disabled — /compact is unavailable.');
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
try {
|
|
865
|
+
const { runCompactCommand } = await import('../../runtime/commands/compact.js');
|
|
866
|
+
const result = await runCompactCommand([], {
|
|
867
|
+
workspaceRoot: process.cwd(),
|
|
868
|
+
sessionId: this.localSessionId,
|
|
869
|
+
store: this.store,
|
|
870
|
+
trigger,
|
|
871
|
+
writeOutput: (_payload, text) => {
|
|
872
|
+
if (text.length > 0)
|
|
873
|
+
this.appendSystemLine(text);
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
if (result.status === 'compacted') {
|
|
877
|
+
// Echo a visible separator into the transcript so the operator
|
|
878
|
+
// immediately sees where the compaction landed. The Ink banner
|
|
879
|
+
// renders the row when the session reloads / resumes.
|
|
880
|
+
this.appendSystemLine(`─── context compacted (${result.turnsBefore} turns → 1 summary, ${trigger}) ───`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
catch (error) {
|
|
884
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
885
|
+
this.appendSystemLine(`/compact failed: ${message}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
774
888
|
/**
|
|
775
889
|
* In-REPL `/privacy` - alpha 6.13. Prints the full 3-mode contract
|
|
776
890
|
* doc + the current mode banner inline. The current mode is fetched
|
|
@@ -1138,13 +1252,70 @@ export class ReplSession {
|
|
|
1138
1252
|
clearTimeout(timer);
|
|
1139
1253
|
}
|
|
1140
1254
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1255
|
+
/**
|
|
1256
|
+
* In-REPL `/status` — Leak L34 (2026-05-27). Surfaces the full
|
|
1257
|
+
* session snapshot (id + age, cwd, permission mode, CLI version,
|
|
1258
|
+
* tokens, dispatches, last cmd, compact boundaries, auth identity,
|
|
1259
|
+
* connection) by delegating к the same `runStatusCommand` the
|
|
1260
|
+
* top-level `pugi status` shell uses. Live REPL state (session
|
|
1261
|
+
* id, token totals, last operator command) flows in through the
|
|
1262
|
+
* context so the slash variant shows MORE than the shell path.
|
|
1263
|
+
*
|
|
1264
|
+
* The renderer routes к the system pane via `appendSystemLine`
|
|
1265
|
+
* so the snapshot lands as a single contiguous block в the
|
|
1266
|
+
* conversation transcript. Migrating к the Ink `<StatusTable>`
|
|
1267
|
+
* mounted directly в the REPL frame is a follow-up sprint —
|
|
1268
|
+
* keeping the line-buffered path here avoids cycling the
|
|
1269
|
+
* conversation pane's render model mid-α7.
|
|
1270
|
+
*/
|
|
1271
|
+
async dispatchStatus() {
|
|
1272
|
+
try {
|
|
1273
|
+
const { runStatusCommand, defaultStatusHome } = await import('../../runtime/commands/status.js');
|
|
1274
|
+
// Find the most-recent operator transcript row + its timestamp
|
|
1275
|
+
// so the snapshot's `Last cmd` field has real content в REPL
|
|
1276
|
+
// mode. Walking от newest end is O(transcript) worst case but
|
|
1277
|
+
// bounded by MAX_TRANSCRIPT_ROWS so this stays cheap.
|
|
1278
|
+
let lastCommand = null;
|
|
1279
|
+
let lastCommandAtEpochMs = null;
|
|
1280
|
+
for (let i = this.state.transcript.length - 1; i >= 0; i -= 1) {
|
|
1281
|
+
const row = this.state.transcript[i];
|
|
1282
|
+
if (row.source === 'operator') {
|
|
1283
|
+
lastCommand = row.text;
|
|
1284
|
+
lastCommandAtEpochMs = row.timestampEpochMs;
|
|
1285
|
+
break;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
const liveTokens = this.state.sessionTokensIn + this.state.sessionTokensOut;
|
|
1289
|
+
const lines = [];
|
|
1290
|
+
await runStatusCommand({
|
|
1291
|
+
cwd: process.cwd(),
|
|
1292
|
+
home: defaultStatusHome(),
|
|
1293
|
+
env: process.env,
|
|
1294
|
+
json: false,
|
|
1295
|
+
liveSessionId: this.state.sessionId ?? null,
|
|
1296
|
+
sessionStartedAtEpochMs: this.state.sessionStartedAtEpochMs,
|
|
1297
|
+
liveTokensUsed: liveTokens >= 0 ? liveTokens : 0,
|
|
1298
|
+
lastCommand,
|
|
1299
|
+
lastCommandAtEpochMs,
|
|
1300
|
+
writeOutput: (_payload, text) => {
|
|
1301
|
+
for (const line of text.split('\n')) {
|
|
1302
|
+
const trimmed = line.replace(/\s+$/u, '');
|
|
1303
|
+
if (trimmed.length > 0)
|
|
1304
|
+
lines.push(trimmed);
|
|
1305
|
+
}
|
|
1306
|
+
},
|
|
1307
|
+
});
|
|
1308
|
+
if (lines.length === 0) {
|
|
1309
|
+
this.appendSystemLine('/status: no output.');
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
for (const line of lines)
|
|
1313
|
+
this.appendSystemLine(line);
|
|
1314
|
+
}
|
|
1315
|
+
catch (error) {
|
|
1316
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1317
|
+
this.appendSystemLine(`/status failed: ${message}`);
|
|
1318
|
+
}
|
|
1148
1319
|
}
|
|
1149
1320
|
/**
|
|
1150
1321
|
* α6.5 `/context` slash handler. Surfaces the three-tier context
|
|
@@ -2208,6 +2379,62 @@ export class ReplSession {
|
|
|
2208
2379
|
// persona -> 'persona'
|
|
2209
2380
|
// system -> 'system'
|
|
2210
2381
|
this.persistRow(row);
|
|
2382
|
+
// Leak L8 (2026-05-27): evaluate the auto-compact gate after
|
|
2383
|
+
// every appendRow that produces a transcript turn. Wrapped in a
|
|
2384
|
+
// setImmediate so the gate never blocks the input-handling fast
|
|
2385
|
+
// path; if the threshold is tripped, the auto-trigger dispatches
|
|
2386
|
+
// `/compact` in the background while the operator keeps typing.
|
|
2387
|
+
if (row.source === 'operator' || row.source === 'persona') {
|
|
2388
|
+
this.maybeAutoCompact();
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Auto-compact gate. Cheap: builds an in-memory token estimate from
|
|
2393
|
+
* the current transcript and consults `evaluateAutoCompact`. When the
|
|
2394
|
+
* gate fires AND a compaction is not already in flight, we dispatch
|
|
2395
|
+
* `/compact` with `trigger='auto'`. The fire-and-forget shape means
|
|
2396
|
+
* the input box stays responsive while the background round-trip
|
|
2397
|
+
* runs.
|
|
2398
|
+
*
|
|
2399
|
+
* Hysteresis: `compactionInFlight` blocks re-entry. The gate is
|
|
2400
|
+
* cleared when the dispatch promise resolves regardless of outcome
|
|
2401
|
+
* so a transient transport failure does not permanently disable the
|
|
2402
|
+
* auto-trigger.
|
|
2403
|
+
*/
|
|
2404
|
+
compactionInFlight = false;
|
|
2405
|
+
maybeAutoCompact() {
|
|
2406
|
+
if (this.compactionInFlight)
|
|
2407
|
+
return;
|
|
2408
|
+
if (!this.store || !this.localSessionId)
|
|
2409
|
+
return;
|
|
2410
|
+
if (process.env['PUGI_AUTOCOMPACT_DISABLED'] === '1')
|
|
2411
|
+
return;
|
|
2412
|
+
// Token estimate from the in-memory transcript. The estimate is a
|
|
2413
|
+
// lower bound on actual context pressure (server-side system
|
|
2414
|
+
// prompts add overhead) but the 4-char/token heuristic plus the
|
|
2415
|
+
// 0.75 default threshold gives generous headroom.
|
|
2416
|
+
const texts = this.state.transcript.map((r) => r.text);
|
|
2417
|
+
const tokenCount = estimateTokensInMany(texts);
|
|
2418
|
+
// Conservative default: assume the smallest commonly-used window
|
|
2419
|
+
// (32k tokens for deepseek-v3.1). Resolving the live model slug
|
|
2420
|
+
// through DispatchFSM + admin-api adds latency on a hot path; the
|
|
2421
|
+
// 0.75 threshold + smallest-window assumption errs toward
|
|
2422
|
+
// EARLY trigger which is the safe direction.
|
|
2423
|
+
const verdict = evaluateAutoCompact({
|
|
2424
|
+
tokenCount,
|
|
2425
|
+
windowSize: 32_000,
|
|
2426
|
+
});
|
|
2427
|
+
if (verdict.kind !== 'fire')
|
|
2428
|
+
return;
|
|
2429
|
+
this.compactionInFlight = true;
|
|
2430
|
+
void (async () => {
|
|
2431
|
+
try {
|
|
2432
|
+
await this.dispatchCompact('auto');
|
|
2433
|
+
}
|
|
2434
|
+
finally {
|
|
2435
|
+
this.compactionInFlight = false;
|
|
2436
|
+
}
|
|
2437
|
+
})();
|
|
2211
2438
|
}
|
|
2212
2439
|
/**
|
|
2213
2440
|
* Best-effort write of one transcript row into the local
|
|
@@ -2258,8 +2485,14 @@ export class ReplSession {
|
|
|
2258
2485
|
* write the restored events.
|
|
2259
2486
|
*/
|
|
2260
2487
|
restoreTranscript(events) {
|
|
2488
|
+
// Leak L8 (2026-05-27): apply compact-boundary masking BEFORE the
|
|
2489
|
+
// row conversion. Events strictly before the latest marker are
|
|
2490
|
+
// condensed into the boundary's `keptTailTurns + marker` slice so
|
|
2491
|
+
// the post-resume transcript starts at the most-recent context
|
|
2492
|
+
// floor rather than re-playing the full pre-compaction history.
|
|
2493
|
+
const masked = applyCompactMask(events);
|
|
2261
2494
|
const rows = [];
|
|
2262
|
-
for (const event of
|
|
2495
|
+
for (const event of masked) {
|
|
2263
2496
|
const row = eventToTranscriptRow(event);
|
|
2264
2497
|
if (row)
|
|
2265
2498
|
rows.push(row);
|
|
@@ -2447,6 +2680,25 @@ function eventToTranscriptRow(event) {
|
|
|
2447
2680
|
timestampEpochMs: event.t,
|
|
2448
2681
|
};
|
|
2449
2682
|
}
|
|
2683
|
+
if (event.kind === 'compaction') {
|
|
2684
|
+
// Leak L8: render the marker as a system separator line on
|
|
2685
|
+
// replay. The full summary text is intentionally NOT inlined here
|
|
2686
|
+
// (a 2k-token summary in the transcript would defeat the purpose
|
|
2687
|
+
// of compacting); the operator sees the "context compacted"
|
|
2688
|
+
// banner and can run `/context` to inspect the marker payload
|
|
2689
|
+
// when they want the details.
|
|
2690
|
+
const compactionPayload = (event.payload ?? null);
|
|
2691
|
+
const trigger = compactionPayload?.trigger === 'auto' ? 'auto' : 'manual';
|
|
2692
|
+
const turns = typeof compactionPayload?.summaryTurnsBefore === 'number'
|
|
2693
|
+
? compactionPayload.summaryTurnsBefore
|
|
2694
|
+
: 0;
|
|
2695
|
+
return {
|
|
2696
|
+
id: randomUUID(),
|
|
2697
|
+
source: 'system',
|
|
2698
|
+
text: `─── context compacted (${turns} turns → 1 summary, ${trigger}) ───`,
|
|
2699
|
+
timestampEpochMs: event.t,
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2450
2702
|
return null;
|
|
2451
2703
|
}
|
|
2452
2704
|
/**
|