@animalabs/agent-framework 0.9.0 → 0.11.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/dist/src/agent.d.ts +1 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +72 -4
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +699 -48
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +62 -7
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +333 -22
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/types.d.ts +56 -0
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +11 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +21 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +2 -2
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +57 -13
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +21 -5
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +23 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +53 -3
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +11 -2
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +27 -11
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +9 -6
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/queue.d.ts.map +1 -1
- package/dist/src/queue.js +9 -2
- package/dist/src/queue.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +12 -2
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +12 -2
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +6 -1
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +1 -1
- package/dist/src/types/trace.d.ts +7 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/busy-agent-scheduler.test.js +29 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -1
- package/dist/test/discord-history-delete-window.test.d.ts +2 -0
- package/dist/test/discord-history-delete-window.test.d.ts.map +1 -0
- package/dist/test/discord-history-delete-window.test.js +81 -0
- package/dist/test/discord-history-delete-window.test.js.map +1 -0
- package/dist/test/discord-history-sync.test.d.ts +2 -0
- package/dist/test/discord-history-sync.test.d.ts.map +1 -0
- package/dist/test/discord-history-sync.test.js +56 -0
- package/dist/test/discord-history-sync.test.js.map +1 -0
- package/dist/test/event-gate-passthrough.test.d.ts +15 -0
- package/dist/test/event-gate-passthrough.test.d.ts.map +1 -0
- package/dist/test/event-gate-passthrough.test.js +289 -0
- package/dist/test/event-gate-passthrough.test.js.map +1 -0
- package/dist/test/explicit-prose-routing.test.js +179 -7
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/framework.test.js +33 -0
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/prose-stream-router.test.js +43 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/provider-acceleration-cooldown.test.d.ts +2 -0
- package/dist/test/provider-acceleration-cooldown.test.d.ts.map +1 -0
- package/dist/test/provider-acceleration-cooldown.test.js +286 -0
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -0
- package/dist/test/puppet-tool-call.test.d.ts +2 -0
- package/dist/test/puppet-tool-call.test.d.ts.map +1 -0
- package/dist/test/puppet-tool-call.test.js +106 -0
- package/dist/test/puppet-tool-call.test.js.map +1 -0
- package/dist/test/release-changelog.test.d.ts +2 -0
- package/dist/test/release-changelog.test.d.ts.map +1 -0
- package/dist/test/release-changelog.test.js +159 -0
- package/dist/test/release-changelog.test.js.map +1 -0
- package/dist/test/tool-result-spill.test.d.ts +1 -1
- package/dist/test/tool-result-spill.test.js +18 -9
- package/dist/test/tool-result-spill.test.js.map +1 -1
- package/dist/test/workspace-path-containment.test.d.ts +19 -0
- package/dist/test/workspace-path-containment.test.d.ts.map +1 -0
- package/dist/test/workspace-path-containment.test.js +91 -0
- package/dist/test/workspace-path-containment.test.js.map +1 -0
- package/dist/test/workspace-watcher.test.js +15 -3
- package/dist/test/workspace-watcher.test.js.map +1 -1
- package/dist/test/xml-round-persist.test.d.ts +19 -0
- package/dist/test/xml-round-persist.test.d.ts.map +1 -0
- package/dist/test/xml-round-persist.test.js +235 -0
- package/dist/test/xml-round-persist.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -1
- package/src/agent.ts +1 -1
- package/src/framework.ts +733 -48
- package/src/gate/event-gate.ts +355 -24
- package/src/gate/types.ts +58 -0
- package/src/mcpl/prose-grammar.ts +21 -0
- package/src/mcpl/prose-stream-router.ts +48 -14
- package/src/modules/discord/index.ts +24 -6
- package/src/modules/workspace/index.ts +53 -3
- package/src/modules/workspace/sync.ts +39 -10
- package/src/modules/workspace/watcher.ts +9 -6
- package/src/queue.ts +8 -2
- package/src/tool-result-history.ts +12 -2
- package/src/types/agent.ts +6 -1
- package/src/types/framework.ts +1 -1
- package/src/types/trace.ts +13 -0
|
@@ -30,7 +30,7 @@ export interface RoutedDelta {
|
|
|
30
30
|
delta: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
type State = 'line-start' | 'maybe-prefix' | 'prefix-token' | 'after-token' | 'body';
|
|
33
|
+
type State = 'line-start' | 'hybrid-indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body';
|
|
34
34
|
|
|
35
35
|
export class ProseStreamRouter {
|
|
36
36
|
private target: string | null;
|
|
@@ -42,8 +42,8 @@ export class ProseStreamRouter {
|
|
|
42
42
|
|
|
43
43
|
constructor(
|
|
44
44
|
private opts: {
|
|
45
|
-
/**
|
|
46
|
-
mode: 'explicit' | 'locus';
|
|
45
|
+
/** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */
|
|
46
|
+
mode: 'explicit' | 'hybrid' | 'locus';
|
|
47
47
|
/** Initial destination: the frozen turn locus (locus mode) or null. */
|
|
48
48
|
initialTarget: string | null;
|
|
49
49
|
/** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */
|
|
@@ -87,7 +87,12 @@ export class ProseStreamRouter {
|
|
|
87
87
|
* no body — dropped, matching delivery which would deliver empty body). */
|
|
88
88
|
finish(): RoutedDelta[] {
|
|
89
89
|
const out: RoutedDelta[] = [];
|
|
90
|
-
if (this.state === '
|
|
90
|
+
if (this.state === 'hybrid-indent') this.emit(this.held, out);
|
|
91
|
+
if (this.state === 'maybe-prefix') {
|
|
92
|
+
const full = this.opts.mode === 'hybrid' ? '>>>' : '>>';
|
|
93
|
+
const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held;
|
|
94
|
+
if (arrows !== full) this.emit(this.held, out);
|
|
95
|
+
}
|
|
91
96
|
// prefix-token / after-token at EOF: prefix without body — nothing to emit.
|
|
92
97
|
this.held = '';
|
|
93
98
|
this.state = 'line-start';
|
|
@@ -97,7 +102,7 @@ export class ProseStreamRouter {
|
|
|
97
102
|
private step(ch: string, out: RoutedDelta[]): void {
|
|
98
103
|
switch (this.state) {
|
|
99
104
|
case 'body':
|
|
100
|
-
if (ch === '\n' && this.opts.mode
|
|
105
|
+
if (ch === '\n' && this.opts.mode !== 'locus') {
|
|
101
106
|
this.emit('\n', out);
|
|
102
107
|
this.state = 'line-start';
|
|
103
108
|
} else {
|
|
@@ -106,34 +111,63 @@ export class ProseStreamRouter {
|
|
|
106
111
|
return;
|
|
107
112
|
|
|
108
113
|
case 'line-start':
|
|
114
|
+
if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) {
|
|
115
|
+
this.state = 'hybrid-indent';
|
|
116
|
+
this.held = ch;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
109
119
|
if (ch === '>') { this.state = 'maybe-prefix'; this.held = '>'; return; }
|
|
110
120
|
this.state = 'body';
|
|
111
121
|
this.step(ch, out);
|
|
112
122
|
return;
|
|
113
123
|
|
|
114
|
-
case '
|
|
115
|
-
if (
|
|
116
|
-
if (this.held
|
|
124
|
+
case 'hybrid-indent':
|
|
125
|
+
if (ch === ' ' || ch === '\t') { this.held += ch; return; }
|
|
126
|
+
if (ch === '>') { this.held += ch; this.state = 'maybe-prefix'; return; }
|
|
127
|
+
this.emit(this.held, out);
|
|
128
|
+
this.held = '';
|
|
129
|
+
this.state = 'body';
|
|
130
|
+
this.step(ch, out);
|
|
131
|
+
return;
|
|
132
|
+
|
|
133
|
+
case 'maybe-prefix': {
|
|
134
|
+
const prefix = this.opts.mode === 'hybrid' ? '>>>' : '>>';
|
|
135
|
+
const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held;
|
|
136
|
+
if (ch === '>' && arrows.length < prefix.length) {
|
|
137
|
+
this.held += '>';
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (arrows === prefix) {
|
|
141
|
+
if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) {
|
|
142
|
+
this.held = '';
|
|
143
|
+
this.state = 'prefix-gap';
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
117
146
|
if (ch === '\n' || ch === ' ' || ch === '\t') {
|
|
118
|
-
|
|
119
|
-
this.emit(this.held, out);
|
|
147
|
+
if (this.opts.mode === 'explicit') this.emit(this.held, out);
|
|
120
148
|
this.held = '';
|
|
121
|
-
this.state = 'body';
|
|
122
|
-
this.step(ch, out);
|
|
149
|
+
this.state = ch === '\n' ? 'line-start' : 'body';
|
|
150
|
+
if (ch !== '\n') this.step(ch, out);
|
|
123
151
|
return;
|
|
124
152
|
}
|
|
125
|
-
// real prefix begins
|
|
126
153
|
this.state = 'prefix-token';
|
|
127
154
|
this.token = ch;
|
|
128
155
|
this.held = '';
|
|
129
156
|
return;
|
|
130
157
|
}
|
|
131
|
-
// held === '>' and ch !== '>': plain body starting with '>'
|
|
132
158
|
this.emit(this.held, out);
|
|
133
159
|
this.held = '';
|
|
134
160
|
this.state = 'body';
|
|
135
161
|
this.step(ch, out);
|
|
136
162
|
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case 'prefix-gap':
|
|
166
|
+
if (ch === ' ' || ch === '\t') return;
|
|
167
|
+
if (ch === '\n') { this.target = null; this.state = 'line-start'; return; }
|
|
168
|
+
this.state = 'prefix-token';
|
|
169
|
+
this.token = ch;
|
|
170
|
+
return;
|
|
137
171
|
|
|
138
172
|
case 'prefix-token':
|
|
139
173
|
if (ch === ' ' || ch === '\t') { this.applyTarget(); this.state = 'after-token'; this.sawBang = false; return; }
|
|
@@ -754,7 +754,7 @@ export class DiscordModule implements Module {
|
|
|
754
754
|
});
|
|
755
755
|
|
|
756
756
|
// Build a map of stored message external IDs -> internal data
|
|
757
|
-
const storedMessageMap = new Map<string, { internalId: string; content: string }>();
|
|
757
|
+
const storedMessageMap = new Map<string, { internalId: string; content: string; sourceTimestamp?: number }>();
|
|
758
758
|
for (const msg of storedMessages) {
|
|
759
759
|
const external = msg.metadata?.external as { id?: string } | undefined;
|
|
760
760
|
if (external?.id) {
|
|
@@ -766,6 +766,9 @@ export class DiscordModule implements Module {
|
|
|
766
766
|
storedMessageMap.set(external.id, {
|
|
767
767
|
internalId: msg.id,
|
|
768
768
|
content: textContent,
|
|
769
|
+
sourceTimestamp: typeof msg.metadata?.timestamp === 'number'
|
|
770
|
+
? msg.metadata.timestamp
|
|
771
|
+
: undefined,
|
|
769
772
|
});
|
|
770
773
|
}
|
|
771
774
|
}
|
|
@@ -774,8 +777,10 @@ export class DiscordModule implements Module {
|
|
|
774
777
|
let editedMessages = 0;
|
|
775
778
|
let deletedMessages = 0;
|
|
776
779
|
|
|
777
|
-
//
|
|
778
|
-
|
|
780
|
+
// Discord history is newest-first, while addMessage appends to the stored
|
|
781
|
+
// conversation. Process oldest-first so missed messages retain chronology.
|
|
782
|
+
const chronologicalMessages = [...discordMessages].reverse();
|
|
783
|
+
for (const discordMsg of chronologicalMessages) {
|
|
779
784
|
const stored = storedMessageMap.get(discordMsg.id);
|
|
780
785
|
|
|
781
786
|
if (!stored) {
|
|
@@ -810,10 +815,23 @@ export class DiscordModule implements Module {
|
|
|
810
815
|
}
|
|
811
816
|
}
|
|
812
817
|
|
|
813
|
-
// Check for deletes
|
|
814
|
-
//
|
|
818
|
+
// Check for deletes only inside the history we actually fetched. A short
|
|
819
|
+
// response covers complete available history; a full page has an older
|
|
820
|
+
// boundary whose tied millisecond must be preserved.
|
|
821
|
+
const fetchedCompleteHistory = discordMessages.length < scrollback;
|
|
822
|
+
const oldestFetchedTimestamp = discordMessages.length > 0
|
|
823
|
+
? Math.min(...discordMessages.map((msg) => msg.timestamp.getTime()))
|
|
824
|
+
: undefined;
|
|
815
825
|
for (const [externalId, stored] of storedMessageMap) {
|
|
816
|
-
|
|
826
|
+
const isInsideFetchedWindow = fetchedCompleteHistory || (
|
|
827
|
+
stored.sourceTimestamp !== undefined &&
|
|
828
|
+
oldestFetchedTimestamp !== undefined &&
|
|
829
|
+
// Discord timestamps are millisecond-precise, so an older message just
|
|
830
|
+
// beyond the page can tie the oldest fetched timestamp. Preserve ties
|
|
831
|
+
// rather than guessing that an unseen boundary message was deleted.
|
|
832
|
+
stored.sourceTimestamp > oldestFetchedTimestamp
|
|
833
|
+
);
|
|
834
|
+
if (isInsideFetchedWindow && !discordMessageMap.has(externalId)) {
|
|
817
835
|
// Message exists in our store but not in Discord - it was deleted
|
|
818
836
|
// Note: This only catches deletes within the scrollback window
|
|
819
837
|
this.ctx.removeMessage(stored.internalId);
|
|
@@ -1080,6 +1080,47 @@ export class WorkspaceModule implements Module {
|
|
|
1080
1080
|
return { data: blob };
|
|
1081
1081
|
}
|
|
1082
1082
|
|
|
1083
|
+
/**
|
|
1084
|
+
* Read a file's raw bytes straight from the mount's filesystem, bypassing
|
|
1085
|
+
* the Chronicle tree.
|
|
1086
|
+
*
|
|
1087
|
+
* For material that is *passing through* rather than being kept: an upload,
|
|
1088
|
+
* a render being shipped somewhere, a file handed to a service. The store
|
|
1089
|
+
* path (`readBinary`) is append-only, so reading through it means every
|
|
1090
|
+
* byte is retained forever and the mount's `maxFileSize` guard applies —
|
|
1091
|
+
* correct for the resident's memory, wrong for egress, where paying a
|
|
1092
|
+
* permanent cost to send something once is the wrong trade.
|
|
1093
|
+
*
|
|
1094
|
+
* Same mount boundary and traversal guard as every other path here: only
|
|
1095
|
+
* declared mounts, nothing above their root. Deliberately does NOT sync,
|
|
1096
|
+
* register, or hash anything — it reads and returns.
|
|
1097
|
+
*/
|
|
1098
|
+
async readBinaryFromDisk(
|
|
1099
|
+
mountPrefixedPath: string,
|
|
1100
|
+
opts: { maxBytes?: number } = {},
|
|
1101
|
+
): Promise<{ data: Buffer; absolutePath: string } | { error: string }> {
|
|
1102
|
+
let mount: MountState;
|
|
1103
|
+
let relativePath: string;
|
|
1104
|
+
try {
|
|
1105
|
+
({ mount, relativePath } = this.parsePath(mountPrefixedPath));
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
1108
|
+
}
|
|
1109
|
+
const absolutePath = resolve(mount.config.path, relativePath);
|
|
1110
|
+
try {
|
|
1111
|
+
const info = await stat(absolutePath);
|
|
1112
|
+
if (!info.isFile()) return { error: `Not a file: ${mountPrefixedPath}` };
|
|
1113
|
+
if (opts.maxBytes !== undefined && info.size > opts.maxBytes) {
|
|
1114
|
+
return { error: `File too large: ${info.size} > ${opts.maxBytes} bytes` };
|
|
1115
|
+
}
|
|
1116
|
+
return { data: await readFile(absolutePath), absolutePath };
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
const code = (error as { code?: string }).code;
|
|
1119
|
+
if (code === 'ENOENT') return { error: `File not found on disk: ${mountPrefixedPath}` };
|
|
1120
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1083
1124
|
/**
|
|
1084
1125
|
* Parse a mount-prefixed path into (mountName, relativePath).
|
|
1085
1126
|
*/
|
|
@@ -1093,10 +1134,12 @@ export class WorkspaceModule implements Module {
|
|
|
1093
1134
|
throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
|
|
1094
1135
|
}
|
|
1095
1136
|
|
|
1096
|
-
// Path traversal guard (CWE-22): ensure resolved path stays within mount
|
|
1137
|
+
// Path traversal guard (CWE-22): ensure resolved path stays within mount.
|
|
1138
|
+
// Containment must compare with the PLATFORM separator (isContainedPath):
|
|
1139
|
+
// resolve() emits backslashes on Windows, so a '/'-suffixed root never
|
|
1140
|
+
// prefix-matches there and every in-mount path was rejected as traversal.
|
|
1097
1141
|
const resolved = resolve(mount.config.path, relativePath);
|
|
1098
|
-
|
|
1099
|
-
if (resolved !== mount.config.path && !resolved.startsWith(mountRoot)) {
|
|
1142
|
+
if (!isContainedPath(mount.config.path, resolved)) {
|
|
1100
1143
|
throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
|
|
1101
1144
|
}
|
|
1102
1145
|
|
|
@@ -1919,6 +1962,7 @@ export class WorkspaceModule implements Module {
|
|
|
1919
1962
|
private async handleSync(input: SyncInput): Promise<ToolResult> {
|
|
1920
1963
|
const store = this.getStore();
|
|
1921
1964
|
const allResults: Array<{ mount: string; synced: string[]; conflicts: ConflictInfo[] }> = [];
|
|
1965
|
+
const allSkipped: Array<{ mount: string; path: string; reason: string }> = [];
|
|
1922
1966
|
|
|
1923
1967
|
let mountsToSync: Array<{ name: string; mount: MountState }>;
|
|
1924
1968
|
if (input.mount) {
|
|
@@ -1951,6 +1995,11 @@ export class WorkspaceModule implements Module {
|
|
|
1951
1995
|
});
|
|
1952
1996
|
this.emitFsEvents(name, result.synced, result.conflicts);
|
|
1953
1997
|
}
|
|
1998
|
+
// Skips were previously computed and dropped, so "nothing synced" and
|
|
1999
|
+
// "your file was refused" looked identical from the outside. Say which.
|
|
2000
|
+
for (const s of result.skipped) {
|
|
2001
|
+
allSkipped.push({ mount: name, path: s.path, reason: s.reason });
|
|
2002
|
+
}
|
|
1954
2003
|
}
|
|
1955
2004
|
|
|
1956
2005
|
return {
|
|
@@ -1959,6 +2008,7 @@ export class WorkspaceModule implements Module {
|
|
|
1959
2008
|
results: allResults,
|
|
1960
2009
|
totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
|
|
1961
2010
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
2011
|
+
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
1962
2012
|
},
|
|
1963
2013
|
};
|
|
1964
2014
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import { readFile, writeFile, mkdir, readdir, stat, access } from 'node:fs/promises';
|
|
11
|
-
import { join, dirname, relative, resolve } from 'node:path';
|
|
11
|
+
import { join, dirname, relative, resolve, sep } from 'node:path';
|
|
12
12
|
import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
|
|
13
13
|
import type { MountState } from './types.js';
|
|
14
14
|
|
|
@@ -19,8 +19,11 @@ export const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
|
19
19
|
* Returns the absolute path, or null if the path is outside the mount.
|
|
20
20
|
*/
|
|
21
21
|
function safePath(mountPath: string, relativePath: string): string | null {
|
|
22
|
+
// Compare with the PLATFORM separator: resolve() emits backslashes on
|
|
23
|
+
// Windows, so a '/'-suffixed root never prefix-matches there and every
|
|
24
|
+
// in-mount path was reported as outside the mount.
|
|
22
25
|
const resolved = resolve(mountPath, relativePath);
|
|
23
|
-
const root = mountPath.endsWith(
|
|
26
|
+
const root = mountPath.endsWith(sep) ? mountPath : mountPath + sep;
|
|
24
27
|
if (resolved !== mountPath && !resolved.startsWith(root)) {
|
|
25
28
|
return null;
|
|
26
29
|
}
|
|
@@ -40,6 +43,13 @@ export function hashContent(content: string | Buffer): string {
|
|
|
40
43
|
/**
|
|
41
44
|
* Check if content appears to be binary.
|
|
42
45
|
*/
|
|
46
|
+
export interface SkippedFile {
|
|
47
|
+
/** Relative path of the file that was not synced */
|
|
48
|
+
path: string;
|
|
49
|
+
/** Why, in words the resident can act on */
|
|
50
|
+
reason: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
export function isBinary(buffer: Buffer): boolean {
|
|
44
54
|
// Check for null bytes in first 8KB
|
|
45
55
|
const check = buffer.subarray(0, 8192);
|
|
@@ -68,8 +78,11 @@ export interface SyncResult {
|
|
|
68
78
|
synced: SyncedPath[];
|
|
69
79
|
/** Paths that conflicted (both agent and user modified) — filesystem wins */
|
|
70
80
|
conflicts: ConflictInfo[];
|
|
71
|
-
/** Paths that were skipped
|
|
72
|
-
|
|
81
|
+
/** Paths that were skipped, each with the reason — a silently skipped file
|
|
82
|
+
* is indistinguishable from one that synced cleanly, which is how a
|
|
83
|
+
* resident ends up believing a file is in their workspace when it never
|
|
84
|
+
* arrived. */
|
|
85
|
+
skipped: SkippedFile[];
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
/**
|
|
@@ -93,7 +106,7 @@ export async function syncFromFs(
|
|
|
93
106
|
for (const relativePath of filesToSync) {
|
|
94
107
|
const absolutePath = safePath(mount.config.path, relativePath);
|
|
95
108
|
if (!absolutePath) {
|
|
96
|
-
result.skipped.push(relativePath);
|
|
109
|
+
result.skipped.push({ path: relativePath, reason: 'path resolves outside the mount' });
|
|
97
110
|
continue;
|
|
98
111
|
}
|
|
99
112
|
|
|
@@ -113,13 +126,23 @@ export async function syncFromFs(
|
|
|
113
126
|
const fileStat = await stat(absolutePath);
|
|
114
127
|
if (!fileStat.isFile()) continue;
|
|
115
128
|
if (fileStat.size > maxSize) {
|
|
116
|
-
result.skipped.push(
|
|
129
|
+
result.skipped.push({
|
|
130
|
+
path: relativePath,
|
|
131
|
+
reason: `too large: ${fileStat.size} bytes > maxFileSize ${maxSize}`,
|
|
132
|
+
});
|
|
117
133
|
continue;
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
const buffer = await readFile(absolutePath);
|
|
121
137
|
if (isBinary(buffer)) {
|
|
122
|
-
|
|
138
|
+
// Disk -> store sync is text-only, at ANY size: the tree stores text
|
|
139
|
+
// blobs. Binary that must live in the store has to be written through
|
|
140
|
+
// writeBinary; binary that is only passing through should be read with
|
|
141
|
+
// readBinaryFromDisk instead of synced at all.
|
|
142
|
+
result.skipped.push({
|
|
143
|
+
path: relativePath,
|
|
144
|
+
reason: 'binary file — disk sync stores text only (read it from disk, or write it via a binary-aware tool)',
|
|
145
|
+
});
|
|
123
146
|
continue;
|
|
124
147
|
}
|
|
125
148
|
|
|
@@ -159,8 +182,11 @@ export async function syncFromFs(
|
|
|
159
182
|
|
|
160
183
|
store.treeSet(mount.treeStateId, relativePath, entry);
|
|
161
184
|
result.synced.push({ path: relativePath, op: existing ? 'modified' : 'created' });
|
|
162
|
-
} catch {
|
|
163
|
-
result.skipped.push(
|
|
185
|
+
} catch (error) {
|
|
186
|
+
result.skipped.push({
|
|
187
|
+
path: relativePath,
|
|
188
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
189
|
+
});
|
|
164
190
|
}
|
|
165
191
|
}
|
|
166
192
|
|
|
@@ -276,7 +302,10 @@ async function walkDirectory(
|
|
|
276
302
|
if (results.length >= maxFiles) break;
|
|
277
303
|
|
|
278
304
|
const fullPath = join(dir, entry.name);
|
|
279
|
-
|
|
305
|
+
// Logical workspace paths are '/'-separated everywhere (mount-prefixed
|
|
306
|
+
// paths, chronicle tree paths); node's relative() emits backslashes on
|
|
307
|
+
// Windows, so normalize. No-op on POSIX (sep === '/').
|
|
308
|
+
const relativePath = relative(basePath, fullPath).split(sep).join('/');
|
|
280
309
|
|
|
281
310
|
// Check ignore patterns (simple glob matching)
|
|
282
311
|
if (shouldIgnore(relativePath, entry.name, ignorePatterns)) continue;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { watch, type FSWatcher } from 'chokidar';
|
|
9
9
|
import { mkdirSync, statSync } from 'node:fs';
|
|
10
|
-
import { basename } from 'node:path';
|
|
10
|
+
import { basename, sep } from 'node:path';
|
|
11
11
|
import { type MountConfig } from './types.js';
|
|
12
12
|
|
|
13
13
|
export type FsOp = 'created' | 'modified' | 'deleted';
|
|
@@ -363,20 +363,23 @@ export class MountWatcher {
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
private toRelative(absolutePath: string): string | null {
|
|
366
|
-
|
|
366
|
+
// Chokidar hands back platform-separated absolute paths; compare with
|
|
367
|
+
// the platform separator and normalize the result to the '/'-separated
|
|
368
|
+
// logical form the rest of the workspace uses. No-op on POSIX.
|
|
369
|
+
const base = this.config.path.endsWith(sep)
|
|
367
370
|
? this.config.path
|
|
368
|
-
: this.config.path +
|
|
371
|
+
: this.config.path + sep;
|
|
369
372
|
if (absolutePath.startsWith(base)) {
|
|
370
|
-
return absolutePath.slice(base.length);
|
|
373
|
+
return absolutePath.slice(base.length).split(sep).join('/');
|
|
371
374
|
}
|
|
372
375
|
return null;
|
|
373
376
|
}
|
|
374
377
|
|
|
375
378
|
private isRootPath(absolutePath: string): boolean {
|
|
376
|
-
const root = this.config.path.endsWith(
|
|
379
|
+
const root = this.config.path.endsWith(sep)
|
|
377
380
|
? this.config.path.slice(0, -1)
|
|
378
381
|
: this.config.path;
|
|
379
|
-
const candidate = absolutePath.endsWith(
|
|
382
|
+
const candidate = absolutePath.endsWith(sep)
|
|
380
383
|
? absolutePath.slice(0, -1)
|
|
381
384
|
: absolutePath;
|
|
382
385
|
return candidate === root;
|
package/src/queue.ts
CHANGED
|
@@ -22,8 +22,14 @@ export class ProcessQueueImpl implements ProcessQueue {
|
|
|
22
22
|
if (waiter) {
|
|
23
23
|
waiter(event);
|
|
24
24
|
} else if (event.type === 'external-message') {
|
|
25
|
-
// User input
|
|
26
|
-
|
|
25
|
+
// User input stays ahead of queued internal work, but joins the end of
|
|
26
|
+
// the external-message lane so rapid messages retain arrival order.
|
|
27
|
+
const firstInternal = this.queue.findIndex((queued) => queued.type !== 'external-message');
|
|
28
|
+
if (firstInternal === -1) {
|
|
29
|
+
this.queue.push(event);
|
|
30
|
+
} else {
|
|
31
|
+
this.queue.splice(firstInternal, 0, event);
|
|
32
|
+
}
|
|
27
33
|
} else {
|
|
28
34
|
this.queue.push(event);
|
|
29
35
|
}
|
|
@@ -20,10 +20,20 @@ import { safeSlice } from './safe-slice.js';
|
|
|
20
20
|
* House-safe default inline cap (chars) for tool results, error results, and
|
|
21
21
|
* background-script wake payloads (issue #89: a resident should never eat a
|
|
22
22
|
* 42k blob they didn't ask for). Durable per-residence value comes from
|
|
23
|
-
* `FrameworkConfig.toolResultInlineMaxChars`; a
|
|
23
|
+
* `FrameworkConfig.toolResultInlineMaxChars`; a per-agent value from
|
|
24
24
|
* agent_settings `tool_result_inline_max_chars`.
|
|
25
|
+
*
|
|
26
|
+
* Raised 5000 → 24000 (2026-08-07) on production evidence rather than taste.
|
|
27
|
+
* The first residence to actually run the 5000 default spilled 48 results in
|
|
28
|
+
* 23h, 71% of them ordinary shell output from source review — `git diff`, test
|
|
29
|
+
* runs, `sed -n '450,590p'`-style excerpts, which routinely land at 5–15k. A
|
|
30
|
+
* 5000-char cap is tuned for conversation and turns every file read into a
|
|
31
|
+
* spill round-trip for a resident doing code work. Measured on that corpus:
|
|
32
|
+
* 20000 keeps 76% inline, 24000 keeps 88%, 32000 only reaches 90% — 24000 is
|
|
33
|
+
* the elbow. Genuinely large payloads (full histories, channel registries,
|
|
34
|
+
* broad greps) still spill, which is the point.
|
|
25
35
|
*/
|
|
26
|
-
export const DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS =
|
|
36
|
+
export const DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS = 24000;
|
|
27
37
|
|
|
28
38
|
export function toolResultDataToHistoryString(data: unknown, maxChars?: number): string {
|
|
29
39
|
const fromArray = tryHistoryStringFromContentArray(data);
|
package/src/types/agent.ts
CHANGED
|
@@ -177,8 +177,13 @@ export interface AgentConfig {
|
|
|
177
177
|
* (`>>#channel` / `>>@person` / `>>skip_reply`); unprefixed prose is never
|
|
178
178
|
* delivered — it bounces to a clipboard for a cheap prefixed resend.
|
|
179
179
|
* See docs/explicit-prose-routing.md.
|
|
180
|
+
* - 'hybrid': unprefixed prose keeps locus routing; `>>>destination` routes
|
|
181
|
+
* that publication envelope through the same authorized resolver.
|
|
182
|
+
* See docs/hybrid-prose-routing.md.
|
|
183
|
+
* - 'disabled': generated prose is never published. Only explicit tools may
|
|
184
|
+
* send externally; prose remains in Chronicle with a private suppression receipt.
|
|
180
185
|
*/
|
|
181
|
-
proseRouting?: 'locus' | 'explicit';
|
|
186
|
+
proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled';
|
|
182
187
|
}
|
|
183
188
|
|
|
184
189
|
/** The intentionally small set of agent settings that may change in-process. */
|
package/src/types/framework.ts
CHANGED
|
@@ -145,7 +145,7 @@ export interface FrameworkConfig {
|
|
|
145
145
|
* name, overwritten on collision, retained until the workspace owner
|
|
146
146
|
* deletes it — never auto-GC'd) and replaced inline by a bounded preview
|
|
147
147
|
* plus the file reference; with no writable workspace the fallback is
|
|
148
|
-
* explicit plain truncation. Default
|
|
148
|
+
* explicit plain truncation. Default 24000 (house-safe; issue #89). Must be
|
|
149
149
|
* >= 1000. A resident's own agent_settings value
|
|
150
150
|
* `tool_result_inline_max_chars` (durable, persisted in framework state)
|
|
151
151
|
* takes precedence over this for that agent; the EFFECTIVE cap for every
|
package/src/types/trace.ts
CHANGED
|
@@ -340,6 +340,19 @@ export type TraceEvent =
|
|
|
340
340
|
textLen: number;
|
|
341
341
|
})
|
|
342
342
|
|
|
343
|
+
// Admin puppet: an operator executed a tool AS an agent and stored the
|
|
344
|
+
// tool_use + tool_result pair in that agent's window (Framework.
|
|
345
|
+
// puppetToolCall). Provenance lives HERE and in the host log — the stored
|
|
346
|
+
// messages deliberately byte-match a model-initiated call.
|
|
347
|
+
| (TraceEventBase & {
|
|
348
|
+
type: 'puppet:tool-call';
|
|
349
|
+
agentName: string;
|
|
350
|
+
toolName: string;
|
|
351
|
+
toolUseId: string;
|
|
352
|
+
isError: boolean;
|
|
353
|
+
durationMs: number;
|
|
354
|
+
})
|
|
355
|
+
|
|
343
356
|
// MCPL server connection lifecycle (spawn / handshake / reconnect health).
|
|
344
357
|
// Previously these outcomes were visible only on the host process's own
|
|
345
358
|
// stderr, so a server that lost the boot handshake race just went missing.
|