zigmini 0.9.0-beta.3 → 1.0.0-beta.1
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/README.ko.md +24 -4
- package/README.md +24 -4
- package/assets/agent/gateway-rules.md +1 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +51 -11
- package/docs/manual.md +52 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +14 -5
- package/src/approvals.ts +6 -1
- package/src/cli/main.ts +33 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +170 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- package/web/workstatus.js +36 -32
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { INSTALL_RULES_FILE, INSTALL_RULES_HEADING, packageRulesDrop, PACKAGE_RULES_FILE, PACKAGE_RULES_HEADING, readRulesFile } from "../../src/agent-rules.ts";
|
|
5
|
+
import { applyRequestRules, loadRequestRules, type ProviderRequestEvent } from "../../src/agent-request-rules.ts";
|
|
6
|
+
import { loadEngineSdk } from "../../src/engine/index.ts";
|
|
7
|
+
import { validateAgentFields } from "../../src/agent/definition.ts";
|
|
8
|
+
import { runtimeHome, runtimePaths } from "../../src/runtime/paths.ts";
|
|
9
|
+
import { agentNamePrompt, nicknamePrompt, readAgentPromptName, readPromptProfile, type PromptProfile } from "../../src/profile-prompt.ts";
|
|
10
|
+
|
|
11
|
+
type Context = { readonly cwd: string; readonly sessionManager: { getSessionId(): string }; readonly ui: { notify(message: string, level: "warning"): void } };
|
|
12
|
+
type ToolCall = { readonly toolName: string; readonly toolCallId: string; readonly input: Record<string, unknown> };
|
|
13
|
+
type Block = { readonly block: true; readonly reason: string };
|
|
14
|
+
type Skill = { readonly name: string; readonly filePath: string };
|
|
15
|
+
type Start = { readonly systemPrompt: string; readonly systemPromptOptions: { readonly skills?: readonly Skill[] } };
|
|
16
|
+
type Events = {
|
|
17
|
+
session_start: unknown; session_shutdown: unknown; before_agent_start: Start;
|
|
18
|
+
before_provider_request: ProviderRequestEvent; tool_call: ToolCall; tool_result: { readonly toolCallId: string };
|
|
19
|
+
input: { readonly text: string }; agent_end: unknown; session_abort: unknown;
|
|
20
|
+
};
|
|
21
|
+
interface ExtensionApi {
|
|
22
|
+
readonly sessionKind: string;
|
|
23
|
+
readonly sessionContext: Readonly<Record<string, string>>;
|
|
24
|
+
readonly rpc: { emit(name: string, data: unknown): void; handle(name: string, handler: () => unknown): void };
|
|
25
|
+
on<K extends keyof Events>(event: K, handler: (event: Events[K], ctx: Context) => unknown, options?: { previewSafe: boolean }): void;
|
|
26
|
+
setActiveTools(names: string[]): void;
|
|
27
|
+
getCommands(): readonly { readonly name: string; readonly source: string }[];
|
|
28
|
+
appendEntry(name: string, data: unknown): void;
|
|
29
|
+
registerTool(tool: HistoryTool): void;
|
|
30
|
+
registerFilesystemPolicy(policy: { check(request: { operation: string; canonicalPath: string }): { allow: true } | { allow: false; reason: string } }): void;
|
|
31
|
+
}
|
|
32
|
+
type HistoryTool = {
|
|
33
|
+
name: string; label: string; description: string; parameters: unknown;
|
|
34
|
+
execute(id: string, params: Record<string, unknown>, signal: AbortSignal | undefined): Promise<unknown>;
|
|
35
|
+
};
|
|
36
|
+
type Edit = { readonly oldText: string; readonly newText: string };
|
|
37
|
+
/** The runtime supplies this public SDK. Tests use the same native editor, not a replacement algorithm. */
|
|
38
|
+
export interface AgentSdk {
|
|
39
|
+
createEditTool(cwd: string, options: { operations: {
|
|
40
|
+
readFile(path: string): Promise<Buffer>; writeFile(path: string, content: string): Promise<void>; access(path: string): Promise<void>;
|
|
41
|
+
} }): { execute(id: string, input: { path: string; edits: Edit[] }): Promise<unknown> };
|
|
42
|
+
}
|
|
43
|
+
const BASE_TOOLS = ["read", "write", "edit", "ask_user_question", "zgw_agent_history_search"];
|
|
44
|
+
export const MEMORY_CAP_MESSAGE = "MEMORY.md를 요약해 줄인 뒤 다시 쓰세요.";
|
|
45
|
+
const DAILY_RULES = "Record each day's facts, results and decisions chronologically in notes/daily/YYYY-MM-DD.md (KST). " +
|
|
46
|
+
"Keep only long-lived preferences, established facts and ongoing goals summarized in MEMORY.md; do not copy daily logs into it.";
|
|
47
|
+
export const RESEARCH_RULES = "When you research on the web, write the results to knowledge/notes/companies/<company>.md " +
|
|
48
|
+
"(other topics knowledge/notes/research/<topic>.md) and put each source URL on the line of the fact it supports.";
|
|
49
|
+
const HISTORY_LIMIT = 50;
|
|
50
|
+
const blocked = (reason: string): Block => ({ block: true, reason });
|
|
51
|
+
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
52
|
+
function missing(error: unknown): boolean { return error instanceof Error && "code" in error && error.code === "ENOENT"; }
|
|
53
|
+
function inside(root: string, path: string): boolean {
|
|
54
|
+
const part = relative(root, path);
|
|
55
|
+
return part === "" || (!isAbsolute(part) && part !== ".." && !part.startsWith(`..${sep}`));
|
|
56
|
+
}
|
|
57
|
+
/** Resolve existing links, including a new target's nearest existing parent. Dangling links fail closed. */
|
|
58
|
+
export function agentPath(path: string): string {
|
|
59
|
+
try { lstatSync(path); } catch (error) {
|
|
60
|
+
if (!missing(error)) throw error;
|
|
61
|
+
return join(agentPath(dirname(path)), basename(path));
|
|
62
|
+
}
|
|
63
|
+
return realpathSync(path);
|
|
64
|
+
}
|
|
65
|
+
function owner(path: string): { sessionId: string; pid: number } | undefined {
|
|
66
|
+
try {
|
|
67
|
+
const data: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
68
|
+
if (record(data) && typeof data.sessionId === "string" && Number.isSafeInteger(data.pid) && typeof data.pid === "number" && data.pid > 0) return { sessionId: data.sessionId, pid: data.pid };
|
|
69
|
+
throw new Error("agent_lock_invalid");
|
|
70
|
+
} catch (error) { if (missing(error)) return undefined; throw error; }
|
|
71
|
+
}
|
|
72
|
+
function live(pid: number): boolean {
|
|
73
|
+
try { process.kill(pid, 0); return true; } catch (error) {
|
|
74
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") return false;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** All lock changes share one exclusive claim directory; a conflicting claimant never removes a newer lock. */
|
|
79
|
+
function claimLock(root: string, sessionId: string, release = false): boolean {
|
|
80
|
+
const claim = join(root, ".lock.claim");
|
|
81
|
+
try { mkdirSync(claim); } catch (error) {
|
|
82
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") return false;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const path = join(root, ".lock");
|
|
87
|
+
const held = owner(path);
|
|
88
|
+
const ours = held?.sessionId === sessionId && held.pid === process.pid;
|
|
89
|
+
if (release) { if (ours) unlinkSync(path); return ours; }
|
|
90
|
+
if (held && !ours && live(held.pid)) return false;
|
|
91
|
+
if (!ours) writeFileSync(path, JSON.stringify({ sessionId, pid: process.pid }), { mode: 0o600 });
|
|
92
|
+
return true;
|
|
93
|
+
} finally { rmdirSync(claim); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default function agentControl(pi: ExtensionApi, loadSdk: () => Promise<AgentSdk> = () => loadEngineSdk<AgentSdk>()): void {
|
|
97
|
+
const id = pi.sessionContext.zgw_agent;
|
|
98
|
+
if (pi.sessionContext.zgw_role !== "agent" || pi.sessionKind !== "interactive" || !id) return;
|
|
99
|
+
pi.registerTool({
|
|
100
|
+
name: "zgw_agent_history_search", label: "Agent history search",
|
|
101
|
+
description: "Search this agent's own past conversation messages for a text. Returns the newest matches first (at most 50). Other conversations are never searched.",
|
|
102
|
+
parameters: { type: "object", properties: { query: { type: "string", minLength: 1, maxLength: 200 }, limit: { type: "integer", minimum: 1, maximum: HISTORY_LIMIT } }, required: ["query"], additionalProperties: false },
|
|
103
|
+
async execute(_toolCallId, params, signal) {
|
|
104
|
+
if (typeof params.query !== "string" || params.query === "") throw new Error("agent_history_query_required");
|
|
105
|
+
const limit = typeof params.limit === "number" && Number.isInteger(params.limit) ? Math.min(Math.max(params.limit, 1), HISTORY_LIMIT) : 20;
|
|
106
|
+
const token = readFileSync(runtimePaths().omoTokenFile, "utf8").trim();
|
|
107
|
+
const base = process.env.ZGW_WORK_URL ?? `http://127.0.0.1:${process.env.ZGW_PORT ?? "8700"}`;
|
|
108
|
+
const url = new URL("/internal/agent-history", base);
|
|
109
|
+
// The agent id comes from the session context, never from the model's arguments.
|
|
110
|
+
url.search = new URLSearchParams({ agent: id, query: params.query, limit: String(limit) }).toString();
|
|
111
|
+
const response = await fetch(url, {
|
|
112
|
+
headers: { authorization: `Bearer ${token}` }, redirect: "error",
|
|
113
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
114
|
+
});
|
|
115
|
+
const result: unknown = await response.json();
|
|
116
|
+
if (!response.ok) throw new Error(`agent_history_http_${response.status}`);
|
|
117
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
let root = "", sessionId = "", prompt = "", ready = false, agentName = "";
|
|
121
|
+
let maxBytes = 0;
|
|
122
|
+
let tools: string[] = [], skills: string[] = [];
|
|
123
|
+
const skillSnapshots = new Map<string, string>();
|
|
124
|
+
let memoryCall: string | undefined;
|
|
125
|
+
let ui: Context["ui"] | undefined;
|
|
126
|
+
let startupWarnings: { code: string; message: string }[] = [];
|
|
127
|
+
// session_start precedes the gateway's open_session attachment. Read this snapshot after binding the owner.
|
|
128
|
+
pi.rpc.handle("zgw.agent_warnings", () => ({ warnings: startupWarnings }));
|
|
129
|
+
const warn = (code: string) => {
|
|
130
|
+
const data = { agentId: id, code };
|
|
131
|
+
const message = code === "memory_over_cap" ? `MEMORY.md가 크기 상한을 넘어 이번 세션에서 생략되었습니다. ${MEMORY_CAP_MESSAGE}` : `에이전트 요청이 제한되었습니다: ${code}`;
|
|
132
|
+
if (!ready) startupWarnings.push({ code, message });
|
|
133
|
+
pi.appendEntry("zgw.agent_warning", data);
|
|
134
|
+
pi.rpc.emit("zgw.agent_warning", data);
|
|
135
|
+
ui?.notify(message, "warning");
|
|
136
|
+
};
|
|
137
|
+
const pathDenial = (path: string, write: boolean): string | undefined => {
|
|
138
|
+
if (!ready) return "agent_not_ready";
|
|
139
|
+
if (!inside(root, path)) return "agent_path_outside_knowledge";
|
|
140
|
+
if (!write) return undefined;
|
|
141
|
+
if (inside(join(root, "source"), path)) return "agent_source_read_only";
|
|
142
|
+
if (path === root || inside(join(root, ".lock"), path) || inside(join(root, ".lock.claim"), path)) return "agent_lock_protected";
|
|
143
|
+
if (!claimLock(root, sessionId)) return "agent_knowledge_locked";
|
|
144
|
+
return undefined;
|
|
145
|
+
};
|
|
146
|
+
pi.registerFilesystemPolicy({ check: ({ operation, canonicalPath }) => {
|
|
147
|
+
try {
|
|
148
|
+
const reason = pathDenial(canonicalPath, operation === "write");
|
|
149
|
+
return reason ? { allow: false, reason } : { allow: true };
|
|
150
|
+
} catch { return { allow: false, reason: "agent_path_unavailable" }; }
|
|
151
|
+
} });
|
|
152
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
153
|
+
ready = false;
|
|
154
|
+
startupWarnings = [];
|
|
155
|
+
ui = ctx.ui;
|
|
156
|
+
pi.setActiveTools([]);
|
|
157
|
+
const token = readFileSync(runtimePaths().omoTokenFile, "utf8").trim();
|
|
158
|
+
const base = process.env.ZGW_WORK_URL ?? `http://127.0.0.1:${process.env.ZGW_PORT ?? "8700"}`;
|
|
159
|
+
const response = await fetch(new URL(`/internal/agent/${encodeURIComponent(id)}`, base), {
|
|
160
|
+
headers: { authorization: `Bearer ${token}` }, redirect: "error", signal: AbortSignal.timeout(5_000),
|
|
161
|
+
});
|
|
162
|
+
if (!response.ok) throw new Error(`agent_definition_http_${response.status}`);
|
|
163
|
+
const definition: unknown = await response.json();
|
|
164
|
+
if (!record(definition) || !record(definition.fields) || typeof definition.body !== "string" || typeof definition.defaultsBody !== "string") throw new Error("agent_definition_invalid");
|
|
165
|
+
validateAgentFields(definition.fields);
|
|
166
|
+
if (typeof definition.fields.name !== "string") throw new Error("agent_name_invalid");
|
|
167
|
+
agentName = definition.fields.name;
|
|
168
|
+
const { memory, rules, tools: toolSettings, skills: allowedSkills } = definition.fields;
|
|
169
|
+
if (!record(memory) || typeof memory.maxBytes !== "number" || !record(rules) || !record(toolSettings) ||
|
|
170
|
+
!Array.isArray(allowedSkills) || !allowedSkills.every((s): s is string => typeof s === "string")) throw new Error("agent_definition_unmerged");
|
|
171
|
+
const expected = join(realpathSync(join(runtimeHome(), "agents")), id, "knowledge");
|
|
172
|
+
root = realpathSync(expected);
|
|
173
|
+
if (root !== expected) throw new Error("agent_knowledge_symlink");
|
|
174
|
+
if (realpathSync(ctx.cwd) !== root) throw new Error("agent_cwd_mismatch");
|
|
175
|
+
sessionId = ctx.sessionManager.getSessionId();
|
|
176
|
+
maxBytes = memory.maxBytes;
|
|
177
|
+
skills = allowedSkills;
|
|
178
|
+
tools = [...BASE_TOOLS, ...(toolSettings.web === true ? ["web_search", "webfetch"] : [])];
|
|
179
|
+
const sections: string[] = [];
|
|
180
|
+
for (const [enabled, path, heading] of [
|
|
181
|
+
[rules.gateway, PACKAGE_RULES_FILE, PACKAGE_RULES_HEADING],
|
|
182
|
+
[rules.install, join(runtimeHome(), INSTALL_RULES_FILE), INSTALL_RULES_HEADING],
|
|
183
|
+
] as const) {
|
|
184
|
+
if (enabled) { const source = readRulesFile(path, path === PACKAGE_RULES_FILE ? packageRulesDrop() : undefined); if (source.text) sections.push(`${heading}\n\n${source.text}`); }
|
|
185
|
+
}
|
|
186
|
+
// body already includes defaultsBody. Put the package daily rule first so defaults can override it.
|
|
187
|
+
sections.push(DAILY_RULES, definition.body);
|
|
188
|
+
if (toolSettings.web === true) sections.push(RESEARCH_RULES);
|
|
189
|
+
const memoryPath = agentPath(join(root, "MEMORY.md"));
|
|
190
|
+
if (!inside(root, memoryPath)) throw new Error("agent_memory_outside_knowledge");
|
|
191
|
+
if (existsSync(memoryPath)) {
|
|
192
|
+
if (statSync(memoryPath).size > maxBytes) warn("memory_over_cap");
|
|
193
|
+
else {
|
|
194
|
+
const snapshot = readFileSync(memoryPath);
|
|
195
|
+
if (snapshot.byteLength > maxBytes) warn("memory_over_cap");
|
|
196
|
+
else sections.push(`<agent-memory>\n${snapshot.toString("utf8")}\n</agent-memory>`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
prompt = sections.join("\n\n");
|
|
200
|
+
if (!claimLock(root, sessionId)) warn("knowledge_locked");
|
|
201
|
+
ready = true;
|
|
202
|
+
pi.setActiveTools(tools);
|
|
203
|
+
});
|
|
204
|
+
pi.on("session_shutdown", () => { if (root && sessionId) claimLock(root, sessionId, true); ready = false; });
|
|
205
|
+
const requestRules = loadRequestRules();
|
|
206
|
+
pi.on("before_provider_request", (event) => applyRequestRules(event, requestRules));
|
|
207
|
+
let profile: PromptProfile = { nickname: undefined, agentName: "" };
|
|
208
|
+
pi.on("before_agent_start", async (event) => {
|
|
209
|
+
if (!ready) throw new Error("agent_not_ready");
|
|
210
|
+
[profile, agentName] = await Promise.all([readPromptProfile(profile), readAgentPromptName(id, agentName)]);
|
|
211
|
+
pi.setActiveTools(tools);
|
|
212
|
+
// File tools remain confined to knowledge. Approved skill bodies are loaded here, not through a read exception.
|
|
213
|
+
for (const skill of event.systemPromptOptions.skills ?? []) {
|
|
214
|
+
if (skills.includes(skill.name) && !skillSnapshots.has(skill.name)) skillSnapshots.set(skill.name, readFileSync(skill.filePath, "utf8"));
|
|
215
|
+
}
|
|
216
|
+
const base = event.systemPrompt.replace(/<available_skills>[\s\S]*?<\/available_skills>/g, "")
|
|
217
|
+
.replace(/<skill_roots>[\s\S]*?<\/skill_roots>/g, "");
|
|
218
|
+
const selected = [...skillSnapshots].map(([name, text]) => `<agent-skill name="${name}">\n${text}\n</agent-skill>`).join("\n\n");
|
|
219
|
+
return { systemPrompt: [base, prompt, selected, agentNamePrompt(agentName), nicknamePrompt(profile.nickname)].filter(Boolean).join("\n\n") };
|
|
220
|
+
});
|
|
221
|
+
pi.on("input", (event) => {
|
|
222
|
+
if (!ready) { warn("agent_not_ready"); return { action: "handled" }; }
|
|
223
|
+
const known = new Set(pi.getCommands().filter((command) => command.source === "skill").map((command) => command.name.replace(/^skill:/, "")));
|
|
224
|
+
for (const match of event.text.matchAll(/(?:^|\s)(\/skill:|\$(?:skill:)?)([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g)) {
|
|
225
|
+
const name = match[2] ?? "";
|
|
226
|
+
if (known.has(name) && !skills.includes(name)) { warn("skill_not_allowed"); return { action: "handled" }; }
|
|
227
|
+
}
|
|
228
|
+
return { action: "continue" };
|
|
229
|
+
});
|
|
230
|
+
pi.on("tool_call", async (event) => {
|
|
231
|
+
if (!ready || !tools.includes(event.toolName)) return blocked("agent_tool_not_allowed");
|
|
232
|
+
if (!["read", "write", "edit"].includes(event.toolName)) return;
|
|
233
|
+
try {
|
|
234
|
+
if (typeof event.input.path !== "string" || event.input.path === "") return blocked("agent_path_required");
|
|
235
|
+
const expanded = event.input.path.replace(/^~(?=\/|$)/, homedir());
|
|
236
|
+
const lexical = resolve(root, expanded);
|
|
237
|
+
const path = agentPath(lexical);
|
|
238
|
+
const write = event.toolName !== "read";
|
|
239
|
+
const reason = pathDenial(lexical, write) ?? pathDenial(path, write);
|
|
240
|
+
if (reason) return blocked(reason);
|
|
241
|
+
if (write && existsSync(path) && lstatSync(path).nlink > 1) return blocked("agent_linked_file_read_only");
|
|
242
|
+
// Canonicalize the actual argument too; do not leave a checked symlink for the native tool to follow.
|
|
243
|
+
event.input.path = path;
|
|
244
|
+
if (!write || path !== agentPath(join(root, "MEMORY.md"))) return;
|
|
245
|
+
if (memoryCall !== undefined) return blocked("agent_memory_busy");
|
|
246
|
+
memoryCall = event.toolCallId;
|
|
247
|
+
let bytes: number;
|
|
248
|
+
if (event.toolName === "write") {
|
|
249
|
+
if (typeof event.input.content !== "string") throw new Error("agent_write_content_required");
|
|
250
|
+
bytes = Buffer.byteLength(event.input.content);
|
|
251
|
+
} else {
|
|
252
|
+
const edits = event.input.edits;
|
|
253
|
+
if (!Array.isArray(edits) || !edits.every((e): e is Edit => record(e) && typeof e.oldText === "string" && typeof e.newText === "string")) throw new Error("agent_edit_input_invalid");
|
|
254
|
+
// Run the pinned native editor against memory-only operations: BOM, CRLF, fuzzy and multi-edit semantics stay exact.
|
|
255
|
+
const sdk = await loadSdk();
|
|
256
|
+
let result: string | undefined;
|
|
257
|
+
await sdk.createEditTool(root, { operations: {
|
|
258
|
+
readFile: async () => readFileSync(path), access: async () => {},
|
|
259
|
+
writeFile: async (_path, content) => { result = content; },
|
|
260
|
+
} }).execute(event.toolCallId, { path, edits });
|
|
261
|
+
if (result === undefined) throw new Error("agent_edit_result_missing");
|
|
262
|
+
bytes = Buffer.byteLength(result);
|
|
263
|
+
}
|
|
264
|
+
if (bytes > maxBytes) { memoryCall = undefined; return blocked(MEMORY_CAP_MESSAGE); }
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (memoryCall === event.toolCallId) memoryCall = undefined;
|
|
267
|
+
return blocked(error instanceof Error ? error.message : "agent_path_unavailable");
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
pi.on("tool_result", (event) => { if (memoryCall === event.toolCallId) memoryCall = undefined; });
|
|
271
|
+
// A later hook/permission can refuse a call without a tool_result. Its turn end also releases the reservation.
|
|
272
|
+
pi.on("agent_end", () => { memoryCall = undefined; });
|
|
273
|
+
pi.on("session_abort", () => { memoryCall = undefined; });
|
|
274
|
+
}
|
|
@@ -85,7 +85,7 @@ function spawnOmo(): Subprocess {
|
|
|
85
85
|
if (existsSync(socketPath)) rmSync(socketPath);
|
|
86
86
|
const child = Bun.spawn(engine.launchCommand({
|
|
87
87
|
home, socket: socketPath, sessionDir,
|
|
88
|
-
extensions: [join(import.meta.dir, "model-control.ts"), join(import.meta.dir, "task-control.ts")],
|
|
88
|
+
extensions: [join(import.meta.dir, "model-control.ts"), join(import.meta.dir, "task-control.ts"), join(import.meta.dir, "agent-control.ts")],
|
|
89
89
|
...(engineRoot ? { engineRoot } : {}),
|
|
90
90
|
}), {
|
|
91
91
|
cwd: process.env.HOME ?? home,
|
|
@@ -122,7 +122,7 @@ export default function continuityControl<Context extends ContinuityContext>(
|
|
|
122
122
|
const role = pi.sessionContext.zgw_role;
|
|
123
123
|
const isMain =
|
|
124
124
|
pi.sessionKind === "interactive" &&
|
|
125
|
-
(role === "main" || role === undefined) &&
|
|
125
|
+
(role === "main" || role === "agent" || role === undefined) &&
|
|
126
126
|
Boolean(pi.sessionContext.zgw_conv);
|
|
127
127
|
if (!isMain) return;
|
|
128
128
|
|
|
@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
3
3
|
import { runtimePaths } from "../../src/runtime/paths.ts";
|
|
4
4
|
import { agentRules } from "../../src/agent-rules.ts";
|
|
5
5
|
import { applyRequestRules, loadRequestRules, type ProviderRequestEvent } from "../../src/agent-request-rules.ts";
|
|
6
|
+
import { nicknamePrompt, PROFILE_TIMEOUT_MS, readPromptProfile, type PromptProfile } from "../../src/profile-prompt.ts";
|
|
6
7
|
|
|
7
8
|
// The pinned runtime supplies this SDK surface, just as it does for model-control.ts.
|
|
8
9
|
type Entry = { id: string; type: string; customType?: string; data?: unknown; message?: { role: string; content: unknown; toolName?: string; details?: unknown; toolCallId?: string } };
|
|
@@ -107,21 +108,11 @@ function gatewayBase(): string {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
export const DEFAULT_AGENT_NAME = "지그";
|
|
110
|
-
export const AGENT_NAME_TIMEOUT_MS =
|
|
111
|
+
export const AGENT_NAME_TIMEOUT_MS = PROFILE_TIMEOUT_MS;
|
|
111
112
|
|
|
112
113
|
/** The agent name the gateway keeps (GET /api/profile, improvement 59); `fallback` when the gateway does not answer. */
|
|
113
114
|
export async function readAgentName(fallback: string): Promise<string> {
|
|
114
|
-
|
|
115
|
-
const response = await fetch(new URL("/api/profile", gatewayBase()), { signal: AbortSignal.timeout(AGENT_NAME_TIMEOUT_MS) });
|
|
116
|
-
if (!response.ok) throw new Error(`profile_http_${response.status}`);
|
|
117
|
-
const data: unknown = await response.json();
|
|
118
|
-
const name = record(data) && typeof data.agentName === "string" ? data.agentName.replace(/\s+/g, " ").trim() : "";
|
|
119
|
-
if (!name) throw new Error("profile_agent_name_missing");
|
|
120
|
-
return name;
|
|
121
|
-
} catch (error) {
|
|
122
|
-
console.warn(`agent name: keeping "${fallback}":`, error instanceof Error ? error.message : String(error));
|
|
123
|
-
return fallback;
|
|
124
|
-
}
|
|
115
|
+
return (await readPromptProfile({ nickname: undefined, agentName: fallback })).agentName;
|
|
125
116
|
}
|
|
126
117
|
|
|
127
118
|
async function postWork(operation: string, requestKey: string, sourceSessionPath: string, payload: Params, signal: AbortSignal | undefined): Promise<unknown> {
|
|
@@ -162,13 +153,14 @@ export default function taskControl(pi: ExtensionApi): void {
|
|
|
162
153
|
});
|
|
163
154
|
}
|
|
164
155
|
|
|
165
|
-
//
|
|
166
|
-
let
|
|
156
|
+
// Read every turn so profile changes also reach already-open main and work sessions.
|
|
157
|
+
let profile: PromptProfile = { nickname: undefined, agentName: DEFAULT_AGENT_NAME };
|
|
167
158
|
pi.on("before_agent_start", async (event) => {
|
|
168
|
-
|
|
159
|
+
profile = await readPromptProfile(profile);
|
|
160
|
+
const agentName = profile.agentName;
|
|
169
161
|
return { systemPrompt: `${event.systemPrompt}\n\n${isMain
|
|
170
162
|
? `You are the gateway main assistant. Your name is ${agentName}. The user calls you ${agentName}. ` + "Route project implementation and independent long-running work through zgw_work_start with the intended absolute project cwd; do not implement project changes in the main conversation. Keep short answers and orchestration here. These tools acknowledge durable submission, not execution or completion. Use exact work IDs for follow-up. Never replay unknown delivery automatically. Read reports and publish their results with zgw_work_publish; do not repeat published text. When you cannot answer the user immediately, first call zgw_precomment once with one line saying what you are doing now; skip it when you can answer right away, and never send periodic progress or routine work notices with it. When you need the user's answer or permission to run something, ask with ask_user_question instead of a plain chat question, so it stays open in the inbox until answered or cancelled; state the exact action and scope in the question, and cite the chat message it is about as [참조 #<message seq>] when you know its seq. Only that inbox answer covers the shown question and scope: a chat reply or an attached message reference is never permission by itself. In this main conversation the question never pauses your turn, even with waitForAnswer true: the gateway keeps it open in the inbox and returns at once with a ZGW_QUESTION_OPEN note. The answer arrives later as a ZGW_DECISION_COMMAND user message. Until then, do not take the action that answer decides, and keep handling every other message."
|
|
171
|
-
: `You own gateway work ${workId}. Read each ZGW_WORK_COMMAND envelope and preserve its command_seq. Execute with the existing permissions and native OMO capabilities. Report progress, questions, and verified outcomes with zgw_work_report. Turn end is not work completion. On cooperative cancel, clean up owned execution and report remaining activity honestly; interrupt alone is not cancellation.`}${rulesText}` };
|
|
163
|
+
: `You own gateway work ${workId}. Read each ZGW_WORK_COMMAND envelope and preserve its command_seq. Execute with the existing permissions and native OMO capabilities. Report progress, questions, and verified outcomes with zgw_work_report. Turn end is not work completion. On cooperative cancel, clean up owned execution and report remaining activity honestly; interrupt alone is not cancellation.`}${rulesText}\n\n${nicknamePrompt(profile.nickname)}` };
|
|
172
164
|
}, { previewSafe: true });
|
|
173
165
|
|
|
174
166
|
if (isMain) {
|
|
@@ -191,6 +183,24 @@ export default function taskControl(pi: ExtensionApi): void {
|
|
|
191
183
|
});
|
|
192
184
|
}
|
|
193
185
|
|
|
186
|
+
if (isMain) {
|
|
187
|
+
pi.registerTool({
|
|
188
|
+
name: "zgw_agent_list", label: "Agent list",
|
|
189
|
+
description: "List the user's agents read-only: name, one-line description and the web link of each agent's conversation. Use it to answer which agents exist or to give the user the link to open one. Returns no agent knowledge.",
|
|
190
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
191
|
+
async execute(_toolCallId, _params, signal) {
|
|
192
|
+
const token = readFileSync(runtimePaths().omoTokenFile, "utf8").trim();
|
|
193
|
+
const response = await fetch(new URL("/internal/agent-list", gatewayBase()), {
|
|
194
|
+
headers: { authorization: `Bearer ${token}` },
|
|
195
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
196
|
+
});
|
|
197
|
+
const result: unknown = await response.json();
|
|
198
|
+
if (!response.ok) throw new Error(`agent_list_http_${response.status}:${JSON.stringify(result)}`);
|
|
199
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
194
204
|
for (const operation of isMain ? ["start", "send", "list", "get", "cancel", "publish", "report"] : ["report"]) {
|
|
195
205
|
const spec = isMain && operation === "report" ? {
|
|
196
206
|
fields: { kind: { type: "string", enum: ["question"] }, summary: string, details: {} },
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- a/@code-yeongyu/senpi/dist/core/agent-session.js
|
|
2
|
+
+++ b/@code-yeongyu/senpi/dist/core/agent-session.js
|
|
3
|
+
@@ -65,6 +65,7 @@ import { exportSessionToHtml, renderSessionToHtml, } from "./export-html/index.j
|
|
4
|
+
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
|
|
5
|
+
import { reportExtensionHandlerStall } from "./extension-handler-stall-report.js";
|
|
6
|
+
import { ANTHROPIC_SUBSCRIPTION_PROVIDER_ID } from "./extensions/builtin/anthropic-subscription/account-management.js";
|
|
7
|
+
+import { CLAUDE_CODE_DEFAULT_CONTEXT_WINDOW, LEGACY_LONG_CONTEXT_MODEL_IDS } from "./extensions/builtin/anthropic-subscription/context-window.js";
|
|
8
|
+
import { askUserAnswerDisplayText } from "./extensions/builtin/ask-user/format.js";
|
|
9
|
+
import { getPromptCachePrewarmUsage } from "./extensions/builtin/cache-keepalive/prewarm-entry.js";
|
|
10
|
+
import { ModelUsabilityBudgetError, projectModelUsabilityBudget, } from "./extensions/builtin/compaction/model-usability-budget.js";
|
|
11
|
+
@@ -1053,9 +1054,9 @@ export class AgentSession {
|
|
12
|
+
const usable = (model) => chat.some((entry) => same(entry, model)) && (!available.some((entry) => entry.provider === model.provider) || available.some((entry) => same(entry, model))) &&
|
|
13
|
+
!this._retryFallback.isProviderSpent(model.provider) &&
|
|
14
|
+
this._modelRuntime.isFallbackEligible(model.provider);
|
|
15
|
+
- const fits = (model) => {
|
|
16
|
+
- const output = Math.min(32768, model.maxTokens > 0 ? model.maxTokens : 32768, Math.floor(model.contextWindow / 2));
|
|
17
|
+
- return model.contextWindow > 0 && inputTokens + Math.max(settings.reserveTokens, output) <= model.contextWindow;
|
|
18
|
+
+ const fits = (model, window = model.contextWindow) => {
|
|
19
|
+
+ const output = Math.min(32768, model.maxTokens > 0 ? model.maxTokens : 32768, Math.floor(window / 2));
|
|
20
|
+
+ return window > 0 && inputTokens + Math.max(settings.reserveTokens, output) <= window;
|
|
21
|
+
};
|
|
22
|
+
if (explicit) {
|
|
23
|
+
if (typeof override !== "string" || !/^[^/\s]+\/\S+$/.test(override))
|
|
24
|
+
@@ -1080,11 +1081,32 @@ export class AgentSession {
|
|
25
|
+
model.provider === sessionModel.provider && family.test(model.id)) : [];
|
|
26
|
+
// Stable sort preserves registry order within the regular and fast Luna groups.
|
|
27
|
+
preferred.sort((left, right) => Number(/-luna-fast$/.test(left.id)) - Number(/-luna-fast$/.test(right.id)));
|
|
28
|
+
- const candidates = [...(family?.test(sessionModel.id) ? [sessionModel] : []), ...preferred, sessionModel];
|
|
29
|
+
+ // A subscription opens a legacy model's 1M window only with usage credits; a native-1M model needs none.
|
|
30
|
+
+ // Subscription order: the newest Sonnet of the catalog, the main model, the other Sonnets newest first, then any
|
|
31
|
+
+ // other available model, each within the window it opens without credits. Other providers keep light, then main.
|
|
32
|
+
+ const subscription = sessionModel.provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID;
|
|
33
|
+
+ const creditFreeWindow = (model) => subscription && LEGACY_LONG_CONTEXT_MODEL_IDS.has(model.id) ? Math.min(model.contextWindow, CLAUDE_CODE_DEFAULT_CONTEXT_WINDOW) : model.contextWindow;
|
|
34
|
+
+ const version = (model) => {
|
|
35
|
+
+ const match = /^claude-sonnet-(\d{1,3})(?:-(\d{1,3}))?(?=-|$)/.exec(model.id);
|
|
36
|
+
+ return match ? Number(match[1]) * 1000 + Number(match[2] ?? 0) : 0;
|
|
37
|
+
+ };
|
|
38
|
+
+ const light = [...(family?.test(sessionModel.id) ? [sessionModel] : []), ...preferred];
|
|
39
|
+
+ const sonnets = subscription ? chat.filter((model) => model.provider === sessionModel.provider && family?.test(model.id)).sort((left, right) => version(right) - version(left)) : [];
|
|
40
|
+
+ const candidates = subscription ? [...sonnets.slice(0, 1), sessionModel, ...sonnets.slice(1), ...available] : [...light, sessionModel];
|
|
41
|
+
+ // A subscription candidate with a recorded summary block yields to the next one; if every fitting
|
|
42
|
+
+ // candidate is blocked, the first block is the reported cause.
|
|
43
|
+
+ let blocked;
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
- if (candidate.provider === sessionModel.provider && usable(candidate) && fits(candidate))
|
|
46
|
+
+ if (candidate.provider === sessionModel.provider && usable(candidate) && fits(candidate, creditFreeWindow(candidate))) {
|
|
47
|
+
+ if (subscription && summaryUnavailable(this.sessionManager, candidate)) {
|
|
48
|
+
+ blocked ??= candidate;
|
|
49
|
+
+ continue;
|
|
50
|
+
+ }
|
|
51
|
+
return admit(candidate);
|
|
52
|
+
+ }
|
|
53
|
+
}
|
|
54
|
+
+ if (blocked)
|
|
55
|
+
+ return admit(blocked);
|
|
56
|
+
return fail(usable(sessionModel) ? "context_budget" : "model_unavailable");
|
|
57
|
+
}
|
|
58
|
+
_logCompactionModelFallback(override, reason, sessionModel) {
|
|
59
|
+
--- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js
|
|
60
|
+
+++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js
|
|
61
|
+
@@ -252,7 +252,31 @@ export function canStartSummary(context, snapshot) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
+/**
|
|
66
|
+
+ * Gateway: a summary that a quota or account block stops moves once to the next model the resolver offers.
|
|
67
|
+
+ * The failed request recorded its block, so automatic selection skips that model; an explicit model, a model
|
|
68
|
+
+ * already tried and every other failure end the run. When every candidate is blocked the first error is thrown.
|
|
69
|
+
+ */
|
|
70
|
+
export async function runExtensionCompaction(context, snapshot, signal, onProgress) {
|
|
71
|
+
+ const tried = new Set();
|
|
72
|
+
+ let first;
|
|
73
|
+
+ for (;;) {
|
|
74
|
+
+ let selectedModel;
|
|
75
|
+
+ try { ({ selectedModel } = selectSummaryModel(context, snapshot)); }
|
|
76
|
+
+ catch (error) { throw first ?? error; }
|
|
77
|
+
+ const key = selectedModel.provider + "/" + selectedModel.id;
|
|
78
|
+
+ if (tried.has(key))
|
|
79
|
+
+ throw first;
|
|
80
|
+
+ tried.add(key);
|
|
81
|
+
+ try { return await runExtensionCompactionOnce(context, snapshot, signal, onProgress); }
|
|
82
|
+
+ catch (error) {
|
|
83
|
+
+ if (signal?.aborted || (error?.failure?.kind !== "quota" && error?.failure?.kind !== "account_blocked"))
|
|
84
|
+
+ throw error;
|
|
85
|
+
+ first ??= error;
|
|
86
|
+
+ }
|
|
87
|
+
+ }
|
|
88
|
+
+}
|
|
89
|
+
+async function runExtensionCompactionOnce(context, snapshot, signal, onProgress) {
|
|
90
|
+
if (signal?.aborted)
|
|
91
|
+
return undefined;
|
|
92
|
+
const { prompt, messages, inputTokens, selectedModel } = selectSummaryModel(context, snapshot);
|