@animalabs/agent-framework 0.6.8 → 0.6.10
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 +4 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +8 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +14 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +147 -9
- package/dist/src/framework.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +10 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +48 -2
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +2 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +17 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +143 -7
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/types/agent.d.ts +15 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/test/trunk-channel-routing.test.js +5 -1
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/test/workspace-watcher.test.js +161 -9
- package/dist/test/workspace-watcher.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +8 -1
- package/src/framework.ts +156 -9
- package/src/modules/workspace/index.ts +47 -2
- package/src/modules/workspace/types.ts +2 -0
- package/src/modules/workspace/watcher.ts +160 -7
- package/src/types/agent.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animalabs/agent-framework",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.10",
|
|
4
4
|
"description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@animalabs/chronicle": "^0.2.2",
|
|
49
49
|
"@animalabs/context-manager": "^0.5.13",
|
|
50
|
-
"@animalabs/membrane": "^0.5.
|
|
50
|
+
"@animalabs/membrane": "^0.5.74",
|
|
51
51
|
"chokidar": "^4.0.3",
|
|
52
52
|
"discord.js": "^14.25.1",
|
|
53
53
|
"ws": "^8.18.0"
|
|
@@ -62,4 +62,4 @@
|
|
|
62
62
|
"engines": {
|
|
63
63
|
"node": ">=20.0.0"
|
|
64
64
|
}
|
|
65
|
-
}
|
|
65
|
+
}
|
package/src/agent.ts
CHANGED
|
@@ -64,6 +64,10 @@ export class Agent {
|
|
|
64
64
|
readonly refusalHandling: AgentConfig['refusalHandling'];
|
|
65
65
|
/** Prompt-cache TTL forwarded to the provider (see AgentConfig.cacheTtl). */
|
|
66
66
|
readonly cacheTtl: NonNullable<AgentConfig['cacheTtl']>;
|
|
67
|
+
/** Emit prompt-cache markers on requests (see AgentConfig.promptCaching). */
|
|
68
|
+
readonly promptCaching: boolean;
|
|
69
|
+
/** Prefill scaffold user message (see AgentConfig.prefillUserMessage). */
|
|
70
|
+
readonly prefillUserMessage?: string;
|
|
67
71
|
/** Provider-specific request parameters forwarded unchanged by Membrane. */
|
|
68
72
|
readonly providerParams?: Record<string, unknown>;
|
|
69
73
|
readonly sameRoundThinkTextPolicy: AgentConfig['sameRoundThinkTextPolicy'];
|
|
@@ -101,6 +105,8 @@ export class Agent {
|
|
|
101
105
|
this.thinking = config.thinking;
|
|
102
106
|
this.refusalHandling = config.refusalHandling;
|
|
103
107
|
this.cacheTtl = config.cacheTtl ?? '1h';
|
|
108
|
+
this.promptCaching = config.promptCaching ?? true;
|
|
109
|
+
this.prefillUserMessage = config.prefillUserMessage;
|
|
104
110
|
this.providerParams = config.providerParams;
|
|
105
111
|
this.sameRoundThinkTextPolicy = config.sameRoundThinkTextPolicy;
|
|
106
112
|
this.maxStreamTokens = config.maxStreamTokens ?? 150_000;
|
|
@@ -638,8 +644,9 @@ export class Agent {
|
|
|
638
644
|
...(this.thinking !== undefined && { thinking: this.thinking }),
|
|
639
645
|
},
|
|
640
646
|
tools: availableTools.length > 0 ? availableTools : undefined,
|
|
641
|
-
promptCaching:
|
|
647
|
+
promptCaching: this.promptCaching,
|
|
642
648
|
cacheTtl: this.cacheTtl,
|
|
649
|
+
...(this.prefillUserMessage && { prefillUserMessage: this.prefillUserMessage }),
|
|
643
650
|
...(this.providerParams && { providerParams: this.providerParams }),
|
|
644
651
|
assistantParticipant: this.name,
|
|
645
652
|
};
|
package/src/framework.ts
CHANGED
|
@@ -66,6 +66,29 @@ import type { WorkspaceModule } from './modules/workspace/index.js';
|
|
|
66
66
|
import { toolResultDataToHistoryString } from './tool-result-history.js';
|
|
67
67
|
import { splitProseSegments } from './prose-segments.js';
|
|
68
68
|
|
|
69
|
+
/** Detect a supported image media type from magic bytes (the model API
|
|
70
|
+
* rejects mislabeled media types, so trust bytes over extensions).
|
|
71
|
+
* Returns undefined for non-image content. */
|
|
72
|
+
function sniffImageMediaType(data: Buffer): string | undefined {
|
|
73
|
+
if (data.length >= 4 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
|
|
74
|
+
return 'image/png';
|
|
75
|
+
}
|
|
76
|
+
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
|
77
|
+
return 'image/jpeg';
|
|
78
|
+
}
|
|
79
|
+
if (data.length >= 3 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) {
|
|
80
|
+
return 'image/gif';
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
data.length >= 12 &&
|
|
84
|
+
data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 &&
|
|
85
|
+
data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50
|
|
86
|
+
) {
|
|
87
|
+
return 'image/webp';
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
69
92
|
/**
|
|
70
93
|
* Tools whose presence suppresses auto-routing of the surrounding prose,
|
|
71
94
|
* for two distinct reasons:
|
|
@@ -1134,7 +1157,9 @@ export class AgentFramework {
|
|
|
1134
1157
|
...channelTools,
|
|
1135
1158
|
...gateTools,
|
|
1136
1159
|
this.buildAgentSettingsTool(),
|
|
1137
|
-
...(this.getWorkspaceModule()
|
|
1160
|
+
...(this.getWorkspaceModule()
|
|
1161
|
+
? [AgentFramework.SAVE_IMAGE_TOOL, AgentFramework.READ_IMAGE_TOOL]
|
|
1162
|
+
: []),
|
|
1138
1163
|
];
|
|
1139
1164
|
}
|
|
1140
1165
|
|
|
@@ -1915,6 +1940,29 @@ export class AgentFramework {
|
|
|
1915
1940
|
},
|
|
1916
1941
|
};
|
|
1917
1942
|
|
|
1943
|
+
/** Synthesized read_image tool — present when a workspace module is
|
|
1944
|
+
* registered. Returns the image as a native image block in the tool
|
|
1945
|
+
* result, so the agent SEES it in the live turn (history keeps a compact
|
|
1946
|
+
* placeholder via the standard tool-result serializer). */
|
|
1947
|
+
private static readonly READ_IMAGE_TOOL: import('./types/index.js').ToolDefinition = {
|
|
1948
|
+
name: 'read_image',
|
|
1949
|
+
description:
|
|
1950
|
+
'View an image file from a workspace mount. The image is returned into ' +
|
|
1951
|
+
'your context so you can actually see it (e.g. after save_recent_image, ' +
|
|
1952
|
+
'or for images placed in the workspace by other means). Path is ' +
|
|
1953
|
+
'mount-prefixed, e.g. "project/photos/cat.png". Supports png/jpeg/gif/webp.',
|
|
1954
|
+
inputSchema: {
|
|
1955
|
+
type: 'object',
|
|
1956
|
+
properties: {
|
|
1957
|
+
path: {
|
|
1958
|
+
type: 'string',
|
|
1959
|
+
description: 'Mount-prefixed image path, e.g. "project/photos/name.png".',
|
|
1960
|
+
},
|
|
1961
|
+
},
|
|
1962
|
+
required: ['path'],
|
|
1963
|
+
},
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1918
1966
|
private static readonly AGENT_SETTINGS_TOOL: import('./types/index.js').ToolDefinition = {
|
|
1919
1967
|
name: 'agent_settings',
|
|
1920
1968
|
description:
|
|
@@ -3774,18 +3822,30 @@ export class AgentFramework {
|
|
|
3774
3822
|
? event.origin.messageId
|
|
3775
3823
|
: event.eventId;
|
|
3776
3824
|
const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
|
|
3825
|
+
// Resolve human-readable names from the event origin wherever the
|
|
3826
|
+
// surface provided them — a mind reading this should see WHO addressed
|
|
3827
|
+
// it WHERE, not a wall of snowflake ids. Raw ids remain only inside the
|
|
3828
|
+
// tool-argument instructions, where they are the literal values to pass.
|
|
3829
|
+
const origin = (event.origin ?? {}) as Record<string, unknown>;
|
|
3830
|
+
const authorName = typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone';
|
|
3831
|
+
const channelLabel = triggerChannel.label ? `#${triggerChannel.label}` : `"${triggerChannel.channelId}"`;
|
|
3832
|
+
const place = typeof origin.guildName === 'string' && origin.guildName
|
|
3833
|
+
? `${channelLabel} in "${origin.guildName}"`
|
|
3834
|
+
: channelLabel;
|
|
3777
3835
|
content.push({
|
|
3778
3836
|
type: 'text',
|
|
3779
3837
|
text:
|
|
3780
|
-
`\n[Channel invitation]
|
|
3781
|
-
`
|
|
3782
|
-
`
|
|
3838
|
+
`\n[Channel invitation] ${authorName} addressed you in ${place} — ` +
|
|
3839
|
+
`a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
|
|
3840
|
+
`Your options:\n` +
|
|
3841
|
+
`1. Reply without joining — simply write your reply as normal text this turn; it will be delivered to ${channelLabel}.\n` +
|
|
3842
|
+
`2. Join the channel — call channel_open with channelId "${triggerChannel.channelId}" and serverId "${event.serverId}"` +
|
|
3783
3843
|
(maxBackscroll > 0
|
|
3784
|
-
?
|
|
3785
|
-
: `
|
|
3786
|
-
`
|
|
3787
|
-
`
|
|
3788
|
-
`
|
|
3844
|
+
? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${messageId}".\n`
|
|
3845
|
+
: `.\n`) +
|
|
3846
|
+
`3. Stay out — call channel_decline with channelId "${triggerChannel.channelId}", serverId "${event.serverId}", ` +
|
|
3847
|
+
`and messageId "${messageId}" (optionally set acknowledge to an emoji like 👀 so ${authorName} isn't left hanging).\n` +
|
|
3848
|
+
`Doing nothing is also fine.`,
|
|
3789
3849
|
});
|
|
3790
3850
|
metadata.channelInvitation = true;
|
|
3791
3851
|
metadata.channelOpen = false;
|
|
@@ -5111,6 +5171,11 @@ export class AgentFramework {
|
|
|
5111
5171
|
return;
|
|
5112
5172
|
}
|
|
5113
5173
|
|
|
5174
|
+
if (enrichedCall.name === 'read_image') {
|
|
5175
|
+
this.dispatchReadImageToolCall(agentName, enrichedCall);
|
|
5176
|
+
return;
|
|
5177
|
+
}
|
|
5178
|
+
|
|
5114
5179
|
// Route gate_status tool
|
|
5115
5180
|
if (enrichedCall.name === 'gate_status' && this.eventGate) {
|
|
5116
5181
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
@@ -6934,6 +6999,19 @@ export class AgentFramework {
|
|
|
6934
6999
|
.then((result) => {
|
|
6935
7000
|
const durationMs = Date.now() - startTime;
|
|
6936
7001
|
this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
|
|
7002
|
+
// A successful channel_open plants the agent's feet in the opened
|
|
7003
|
+
// channel: pin it as this turn's reply locus so plain-text speech
|
|
7004
|
+
// written right after opening routes THERE. Found live 2026-07-21
|
|
7005
|
+
// (Aria bring-up): open → plain-text reply → "no locus (no
|
|
7006
|
+
// home/active channel, defaultPublishChannel null)" on a turn with
|
|
7007
|
+
// no channel trigger (post-restart continuation). Next turn start
|
|
7008
|
+
// re-resolves the locus as usual (startAgentStream set/delete).
|
|
7009
|
+
if (call.name === 'channel_open' && result?.success) {
|
|
7010
|
+
const opened =
|
|
7011
|
+
(result.data as { channelId?: string } | undefined)?.channelId
|
|
7012
|
+
?? (call.input as { channelId?: string } | undefined)?.channelId;
|
|
7013
|
+
if (opened) this.activeTriggerChannels.set(agentName, opened);
|
|
7014
|
+
}
|
|
6937
7015
|
this.pushEvent({
|
|
6938
7016
|
type: 'tool-result',
|
|
6939
7017
|
callId: call.id,
|
|
@@ -7096,6 +7174,75 @@ export class AgentFramework {
|
|
|
7096
7174
|
});
|
|
7097
7175
|
}
|
|
7098
7176
|
|
|
7177
|
+
/**
|
|
7178
|
+
* Handle the synthesized `read_image` tool: read image bytes from a
|
|
7179
|
+
* workspace mount and return them as a native MCP-shaped content array
|
|
7180
|
+
* (`[{type:'text'…},{type:'image', data, mimeType}]`). The live-turn
|
|
7181
|
+
* converter (tryNativeToolResultContent → membrane tool_result image
|
|
7182
|
+
* blocks) delivers the actual image to the model; history storage keeps
|
|
7183
|
+
* the standard compact `[image: …]` placeholder.
|
|
7184
|
+
*/
|
|
7185
|
+
private dispatchReadImageToolCall(agentName: string, call: ToolCall): void {
|
|
7186
|
+
this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
|
|
7187
|
+
const finish = (result: ToolResult): void => {
|
|
7188
|
+
this.emitTrace({
|
|
7189
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
7190
|
+
module: 'workspace',
|
|
7191
|
+
tool: call.name,
|
|
7192
|
+
callId: call.id,
|
|
7193
|
+
durationMs: 0,
|
|
7194
|
+
...(result.isError ? { error: result.error } : {}),
|
|
7195
|
+
});
|
|
7196
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
|
|
7197
|
+
};
|
|
7198
|
+
void (async (): Promise<void> => {
|
|
7199
|
+
try {
|
|
7200
|
+
const workspace = this.getWorkspaceModule();
|
|
7201
|
+
if (!workspace || typeof workspace.readBinary !== 'function') {
|
|
7202
|
+
throw new Error('read_image requires a workspace module');
|
|
7203
|
+
}
|
|
7204
|
+
const input = (call.input ?? {}) as { path?: unknown };
|
|
7205
|
+
if (typeof input.path !== 'string' || input.path.length === 0) {
|
|
7206
|
+
throw new Error('read_image: `path` (mount-prefixed) is required');
|
|
7207
|
+
}
|
|
7208
|
+
const read = await workspace.readBinary(input.path);
|
|
7209
|
+
if ('error' in read) throw new Error(read.error);
|
|
7210
|
+
const mediaType = sniffImageMediaType(read.data);
|
|
7211
|
+
if (!mediaType) {
|
|
7212
|
+
throw new Error(
|
|
7213
|
+
`read_image: "${input.path}" does not look like a supported image ` +
|
|
7214
|
+
'(png/jpeg/gif/webp — checked by magic bytes, not extension)',
|
|
7215
|
+
);
|
|
7216
|
+
}
|
|
7217
|
+
// Anthropic rejects images >5MB (and >8000px); guard the hard byte
|
|
7218
|
+
// limit here — resizing is out of scope without an image library.
|
|
7219
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
7220
|
+
if (read.data.byteLength > MAX_IMAGE_BYTES) {
|
|
7221
|
+
throw new Error(
|
|
7222
|
+
`read_image: "${input.path}" is ${read.data.byteLength} bytes — ` +
|
|
7223
|
+
`over the ${MAX_IMAGE_BYTES}-byte model limit`,
|
|
7224
|
+
);
|
|
7225
|
+
}
|
|
7226
|
+
finish({
|
|
7227
|
+
success: true,
|
|
7228
|
+
data: [
|
|
7229
|
+
{
|
|
7230
|
+
type: 'text',
|
|
7231
|
+
text: `${input.path} (${mediaType}, ${Math.ceil(read.data.byteLength / 1024)} KB):`,
|
|
7232
|
+
},
|
|
7233
|
+
{ type: 'image', data: read.data.toString('base64'), mimeType: mediaType },
|
|
7234
|
+
],
|
|
7235
|
+
});
|
|
7236
|
+
} catch (error) {
|
|
7237
|
+
finish({
|
|
7238
|
+
success: false,
|
|
7239
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7240
|
+
isError: true,
|
|
7241
|
+
});
|
|
7242
|
+
}
|
|
7243
|
+
})();
|
|
7244
|
+
}
|
|
7245
|
+
|
|
7099
7246
|
/**
|
|
7100
7247
|
* Handle the synthesized `save_recent_image` tool: locate the requested
|
|
7101
7248
|
* image blocks in the calling agent's context (blobs re-inlined, counted
|
|
@@ -651,6 +651,15 @@ export class WorkspaceModule implements Module {
|
|
|
651
651
|
error: err.message,
|
|
652
652
|
} as ProcessEvent);
|
|
653
653
|
},
|
|
654
|
+
onReattach: () => {
|
|
655
|
+
mount.watcherError = null;
|
|
656
|
+
void this.initialScan(name);
|
|
657
|
+
this.ctx?.pushEvent({
|
|
658
|
+
type: 'workspace:watcher-reattached',
|
|
659
|
+
mount: name,
|
|
660
|
+
path: mount.config.path,
|
|
661
|
+
} as ProcessEvent);
|
|
662
|
+
},
|
|
654
663
|
},
|
|
655
664
|
);
|
|
656
665
|
watcher.start();
|
|
@@ -1018,6 +1027,30 @@ export class WorkspaceModule implements Module {
|
|
|
1018
1027
|
};
|
|
1019
1028
|
}
|
|
1020
1029
|
|
|
1030
|
+
/**
|
|
1031
|
+
* Read a file's raw bytes from a mount (through the Chronicle tree, synced
|
|
1032
|
+
* first like the `read` tool). Public API for the framework's synthesized
|
|
1033
|
+
* `read_image` tool and other peer callers that need binary content.
|
|
1034
|
+
*/
|
|
1035
|
+
async readBinary(
|
|
1036
|
+
mountPrefixedPath: string,
|
|
1037
|
+
): Promise<{ data: Buffer } | { error: string }> {
|
|
1038
|
+
let mount: MountState;
|
|
1039
|
+
let relativePath: string;
|
|
1040
|
+
try {
|
|
1041
|
+
({ mount, relativePath } = this.parsePath(mountPrefixedPath));
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
1044
|
+
}
|
|
1045
|
+
const store = this.getStore();
|
|
1046
|
+
await this.ensureSynced(mount, relativePath);
|
|
1047
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
1048
|
+
if (!entry) return { error: `File not found: ${mountPrefixedPath}` };
|
|
1049
|
+
const blob = store.getBlob(entry.blobHash);
|
|
1050
|
+
if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
|
|
1051
|
+
return { data: blob };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1021
1054
|
/**
|
|
1022
1055
|
* Parse a mount-prefixed path into (mountName, relativePath).
|
|
1023
1056
|
*/
|
|
@@ -1662,8 +1695,20 @@ export class WorkspaceModule implements Module {
|
|
|
1662
1695
|
mount.initialSyncDone = true;
|
|
1663
1696
|
}
|
|
1664
1697
|
|
|
1665
|
-
|
|
1666
|
-
|
|
1698
|
+
// If `path` points at a single FILE, grep just that file. Otherwise treat
|
|
1699
|
+
// `path` as a directory prefix (the original behaviour). Previously a file
|
|
1700
|
+
// path produced an empty prefix like "notes.md/" and matched nothing, so
|
|
1701
|
+
// grepping a specific file silently returned zero results.
|
|
1702
|
+
let entries: Array<{ path: string; blobHash: string }>;
|
|
1703
|
+
const fileNode = relativePath
|
|
1704
|
+
? (store.treeGet(mount.treeStateId, relativePath) as { blobHash?: string } | null)
|
|
1705
|
+
: null;
|
|
1706
|
+
if (fileNode && fileNode.blobHash) {
|
|
1707
|
+
entries = [{ path: relativePath, blobHash: fileNode.blobHash }];
|
|
1708
|
+
} else {
|
|
1709
|
+
const prefix = relativePath ? relativePath + '/' : undefined;
|
|
1710
|
+
entries = store.treeList(mount.treeStateId, prefix);
|
|
1711
|
+
}
|
|
1667
1712
|
|
|
1668
1713
|
for (const entry of entries) {
|
|
1669
1714
|
if (fileGlob && !fileGlob.test(entry.path)) continue;
|
|
@@ -25,6 +25,8 @@ export interface MountConfig {
|
|
|
25
25
|
watch?: 'always' | 'on-agent-action' | 'never';
|
|
26
26
|
/** Debounce window in ms for watch: 'always' mode (default: 300) */
|
|
27
27
|
watchDebounceMs?: number;
|
|
28
|
+
/** Poll interval in ms for checking watched root identity changes (default: 2000) */
|
|
29
|
+
watchRootPollMs?: number;
|
|
28
30
|
/**
|
|
29
31
|
* Simple ignore patterns (NOT full gitignore syntax). Supported forms:
|
|
30
32
|
* - Exact name match: ".git", "node_modules"
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { watch, type FSWatcher } from 'chokidar';
|
|
9
|
-
import { mkdirSync } from 'node:fs';
|
|
9
|
+
import { mkdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { basename } from 'node:path';
|
|
10
11
|
import { type MountConfig } from './types.js';
|
|
11
12
|
|
|
12
13
|
export type FsOp = 'created' | 'modified' | 'deleted';
|
|
@@ -30,6 +31,14 @@ export interface WatcherLifecycle {
|
|
|
30
31
|
onReady?: () => void;
|
|
31
32
|
/** Fires on any chokidar-reported error (ENOENT, EACCES, platform faults). */
|
|
32
33
|
onError?: (err: Error) => void;
|
|
34
|
+
/** Fires after the watcher re-attaches to a replaced root inode. */
|
|
35
|
+
onReattach?: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RootIdentity {
|
|
39
|
+
dev: number;
|
|
40
|
+
ino: number;
|
|
41
|
+
birthtimeMs: number;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
/**
|
|
@@ -42,8 +51,13 @@ export class MountWatcher {
|
|
|
42
51
|
// create -> modify collapses to 'created' (single batch); delete always wins.
|
|
43
52
|
private pendingChanges = new Map<string, FsOp>();
|
|
44
53
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
54
|
+
private rootPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
45
55
|
private suppressedPaths = new Set<string>();
|
|
46
56
|
private suppressionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
57
|
+
private rootIdentity: RootIdentity | null = null;
|
|
58
|
+
private detached = false;
|
|
59
|
+
private attaching = false;
|
|
60
|
+
private stopped = true;
|
|
47
61
|
private readonly debounceMs: number;
|
|
48
62
|
private readonly config: MountConfig;
|
|
49
63
|
private readonly onChangeCallback: (changes: FsChange[]) => void;
|
|
@@ -64,8 +78,14 @@ export class MountWatcher {
|
|
|
64
78
|
* Start watching the filesystem.
|
|
65
79
|
*/
|
|
66
80
|
start(): void {
|
|
67
|
-
if (this.watcher) return;
|
|
81
|
+
if (this.watcher || this.rootPollTimer) return;
|
|
82
|
+
|
|
83
|
+
this.stopped = false;
|
|
84
|
+
this.attach();
|
|
85
|
+
this.startRootPolling();
|
|
86
|
+
}
|
|
68
87
|
|
|
88
|
+
private attach(): void {
|
|
69
89
|
// Read-write mounts: ensure the path exists before chokidar attaches.
|
|
70
90
|
// chokidar 4's fs.watch-based backend silently fails to fire events when
|
|
71
91
|
// the watched path OR any of its parent directories don't exist at
|
|
@@ -110,21 +130,39 @@ export class MountWatcher {
|
|
|
110
130
|
this.watcher.on('add', handleEvent('created'));
|
|
111
131
|
this.watcher.on('change', handleEvent('modified'));
|
|
112
132
|
this.watcher.on('unlink', handleEvent('deleted'));
|
|
133
|
+
this.watcher.on('unlinkDir', (dirPath) => {
|
|
134
|
+
if (this.isRootPath(dirPath)) {
|
|
135
|
+
this.detached = true;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
113
138
|
|
|
114
139
|
if (this.lifecycle.onReady) {
|
|
115
140
|
this.watcher.once('ready', this.lifecycle.onReady);
|
|
116
141
|
}
|
|
117
|
-
|
|
118
|
-
this.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
142
|
+
this.watcher.on('error', (err) => {
|
|
143
|
+
this.reportError(err);
|
|
144
|
+
});
|
|
145
|
+
this.watcher.on('raw', (event: string, rawPath: string, _details: unknown) => {
|
|
146
|
+
if (event !== 'rename') return;
|
|
147
|
+
// A rename on the watched root's path/basename is the one signal that
|
|
148
|
+
// survives inode reuse on Linux, where unlinkDir/error never fire.
|
|
149
|
+
if (this.isRootPath(rawPath) || basename(rawPath) === basename(this.config.path)) {
|
|
150
|
+
void this.checkRootLiveness({ force: true });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
this.captureRootIdentity();
|
|
122
155
|
}
|
|
123
156
|
|
|
124
157
|
/**
|
|
125
158
|
* Stop watching.
|
|
126
159
|
*/
|
|
127
160
|
async stop(): Promise<void> {
|
|
161
|
+
this.stopped = true;
|
|
162
|
+
if (this.rootPollTimer) {
|
|
163
|
+
clearInterval(this.rootPollTimer);
|
|
164
|
+
this.rootPollTimer = null;
|
|
165
|
+
}
|
|
128
166
|
if (this.debounceTimer) {
|
|
129
167
|
clearTimeout(this.debounceTimer);
|
|
130
168
|
this.debounceTimer = null;
|
|
@@ -140,6 +178,9 @@ export class MountWatcher {
|
|
|
140
178
|
await this.watcher.close();
|
|
141
179
|
this.watcher = null;
|
|
142
180
|
}
|
|
181
|
+
this.rootIdentity = null;
|
|
182
|
+
this.detached = false;
|
|
183
|
+
this.attaching = false;
|
|
143
184
|
}
|
|
144
185
|
|
|
145
186
|
/**
|
|
@@ -219,6 +260,108 @@ export class MountWatcher {
|
|
|
219
260
|
}, this.debounceMs);
|
|
220
261
|
}
|
|
221
262
|
|
|
263
|
+
private startRootPolling(): void {
|
|
264
|
+
const pollMs = this.config.watchRootPollMs ?? 2000;
|
|
265
|
+
this.rootPollTimer = setInterval(() => {
|
|
266
|
+
void this.checkRootLiveness();
|
|
267
|
+
}, pollMs);
|
|
268
|
+
this.rootPollTimer.unref();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async checkRootLiveness(opts: { force?: boolean } = {}): Promise<void> {
|
|
272
|
+
if (this.stopped || this.attaching) return;
|
|
273
|
+
|
|
274
|
+
let current: RootIdentity;
|
|
275
|
+
try {
|
|
276
|
+
current = this.readRootIdentity();
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (this.isErrno(err, 'ENOENT')) {
|
|
279
|
+
this.detached = true;
|
|
280
|
+
if (this.config.mode === 'read-write') {
|
|
281
|
+
await this.reattach();
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
this.reportError(err);
|
|
286
|
+
if (opts.force) {
|
|
287
|
+
this.detached = true;
|
|
288
|
+
await this.reattach();
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!this.rootIdentity && !this.detached) {
|
|
294
|
+
await this.reattach();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!opts.force && !this.detached && this.rootIdentity
|
|
299
|
+
&& this.sameIdentity(current, this.rootIdentity)) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await this.reattach();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async reattach(): Promise<void> {
|
|
307
|
+
if (this.attaching || this.stopped) return;
|
|
308
|
+
|
|
309
|
+
this.attaching = true;
|
|
310
|
+
try {
|
|
311
|
+
const oldWatcher = this.watcher;
|
|
312
|
+
this.watcher = null;
|
|
313
|
+
if (oldWatcher) {
|
|
314
|
+
await oldWatcher.close();
|
|
315
|
+
}
|
|
316
|
+
if (this.stopped) return;
|
|
317
|
+
|
|
318
|
+
this.attach();
|
|
319
|
+
if (!this.detached && this.rootIdentity) {
|
|
320
|
+
this.lifecycle.onReattach?.();
|
|
321
|
+
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
this.reportError(err);
|
|
324
|
+
} finally {
|
|
325
|
+
this.attaching = false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private captureRootIdentity(): void {
|
|
330
|
+
try {
|
|
331
|
+
this.rootIdentity = this.readRootIdentity();
|
|
332
|
+
this.detached = false;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (this.isErrno(err, 'ENOENT')) {
|
|
335
|
+
this.rootIdentity = null;
|
|
336
|
+
this.detached = true;
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.rootIdentity = null;
|
|
340
|
+
this.detached = false;
|
|
341
|
+
this.reportError(err);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private readRootIdentity(): RootIdentity {
|
|
346
|
+
const stat = statSync(this.config.path);
|
|
347
|
+
return { dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private sameIdentity(a: RootIdentity, b: RootIdentity): boolean {
|
|
351
|
+
// birthtimeMs is only a secondary tripwire: sub-millisecond replacements
|
|
352
|
+
// can reuse both the inode and timestamp, so raw rename remains essential.
|
|
353
|
+
return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private isErrno(err: unknown, code: string): boolean {
|
|
357
|
+
return typeof err === 'object' && err !== null && 'code' in err
|
|
358
|
+
&& (err as NodeJS.ErrnoException).code === code;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private reportError(err: unknown): void {
|
|
362
|
+
this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
363
|
+
}
|
|
364
|
+
|
|
222
365
|
private toRelative(absolutePath: string): string | null {
|
|
223
366
|
const base = this.config.path.endsWith('/')
|
|
224
367
|
? this.config.path
|
|
@@ -228,4 +371,14 @@ export class MountWatcher {
|
|
|
228
371
|
}
|
|
229
372
|
return null;
|
|
230
373
|
}
|
|
374
|
+
|
|
375
|
+
private isRootPath(absolutePath: string): boolean {
|
|
376
|
+
const root = this.config.path.endsWith('/')
|
|
377
|
+
? this.config.path.slice(0, -1)
|
|
378
|
+
: this.config.path;
|
|
379
|
+
const candidate = absolutePath.endsWith('/')
|
|
380
|
+
? absolutePath.slice(0, -1)
|
|
381
|
+
: absolutePath;
|
|
382
|
+
return candidate === root;
|
|
383
|
+
}
|
|
231
384
|
}
|
package/src/types/agent.ts
CHANGED
|
@@ -68,6 +68,23 @@ export interface AgentConfig {
|
|
|
68
68
|
*/
|
|
69
69
|
cacheTtl?: '5m' | '1h';
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Whether to emit prompt-cache markers (cache_control) on requests.
|
|
73
|
+
* Default true (Anthropic API). Set false for transports that reject
|
|
74
|
+
* cache_control — e.g. Bedrock legacy Claude models, which fail with
|
|
75
|
+
* "your request did not allow prompt caching".
|
|
76
|
+
*/
|
|
77
|
+
promptCaching?: boolean;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Prefill-formatter support: a synthetic user message appended after the
|
|
81
|
+
* conversation (e.g. chapterx's `<cmd>cat untitled.txt</cmd>` CLI-sim
|
|
82
|
+
* scaffold). Forwarded verbatim to membrane's prefill formatters; ignored
|
|
83
|
+
* by native formatting. Part of reproducing a prefill-era bot's exact
|
|
84
|
+
* prompting structure when migrating it into a resident.
|
|
85
|
+
*/
|
|
86
|
+
prefillUserMessage?: string;
|
|
87
|
+
|
|
71
88
|
/** Provider-specific request parameters (for example Responses reasoning
|
|
72
89
|
* and server-side compaction settings). */
|
|
73
90
|
providerParams?: Record<string, unknown>;
|