astrocode-workflow 0.0.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/LICENSE +1 -0
- package/README.md +85 -0
- package/dist/agents/commands.d.ts +9 -0
- package/dist/agents/commands.js +121 -0
- package/dist/agents/prompts.d.ts +2 -0
- package/dist/agents/prompts.js +27 -0
- package/dist/agents/registry.d.ts +6 -0
- package/dist/agents/registry.js +223 -0
- package/dist/agents/types.d.ts +14 -0
- package/dist/agents/types.js +8 -0
- package/dist/config/config-handler.d.ts +4 -0
- package/dist/config/config-handler.js +46 -0
- package/dist/config/defaults.d.ts +3 -0
- package/dist/config/defaults.js +3 -0
- package/dist/config/loader.d.ts +11 -0
- package/dist/config/loader.js +48 -0
- package/dist/config/schema.d.ts +176 -0
- package/dist/config/schema.js +198 -0
- package/dist/hooks/continuation-enforcer.d.ts +26 -0
- package/dist/hooks/continuation-enforcer.js +166 -0
- package/dist/hooks/tool-output-truncator.d.ts +17 -0
- package/dist/hooks/tool-output-truncator.js +56 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +108 -0
- package/dist/shared/deep-merge.d.ts +8 -0
- package/dist/shared/deep-merge.js +25 -0
- package/dist/shared/hash.d.ts +1 -0
- package/dist/shared/hash.js +4 -0
- package/dist/shared/log.d.ts +7 -0
- package/dist/shared/log.js +24 -0
- package/dist/shared/model-tuning.d.ts +9 -0
- package/dist/shared/model-tuning.js +28 -0
- package/dist/shared/paths.d.ts +19 -0
- package/dist/shared/paths.js +51 -0
- package/dist/shared/text.d.ts +4 -0
- package/dist/shared/text.js +19 -0
- package/dist/shared/time.d.ts +1 -0
- package/dist/shared/time.js +3 -0
- package/dist/state/adapters/index.d.ts +39 -0
- package/dist/state/adapters/index.js +119 -0
- package/dist/state/db.d.ts +17 -0
- package/dist/state/db.js +83 -0
- package/dist/state/ids.d.ts +8 -0
- package/dist/state/ids.js +25 -0
- package/dist/state/schema.d.ts +2 -0
- package/dist/state/schema.js +247 -0
- package/dist/state/types.d.ts +70 -0
- package/dist/state/types.js +1 -0
- package/dist/tools/artifacts.d.ts +18 -0
- package/dist/tools/artifacts.js +71 -0
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +100 -0
- package/dist/tools/init.d.ts +8 -0
- package/dist/tools/init.js +41 -0
- package/dist/tools/injects.d.ts +23 -0
- package/dist/tools/injects.js +99 -0
- package/dist/tools/repair.d.ts +8 -0
- package/dist/tools/repair.js +25 -0
- package/dist/tools/run.d.ts +13 -0
- package/dist/tools/run.js +54 -0
- package/dist/tools/spec.d.ts +13 -0
- package/dist/tools/spec.js +41 -0
- package/dist/tools/stage.d.ts +23 -0
- package/dist/tools/stage.js +284 -0
- package/dist/tools/status.d.ts +8 -0
- package/dist/tools/status.js +107 -0
- package/dist/tools/story.d.ts +23 -0
- package/dist/tools/story.js +85 -0
- package/dist/tools/workflow.d.ts +8 -0
- package/dist/tools/workflow.js +197 -0
- package/dist/ui/inject.d.ts +5 -0
- package/dist/ui/inject.js +9 -0
- package/dist/ui/toasts.d.ts +13 -0
- package/dist/ui/toasts.js +39 -0
- package/dist/workflow/artifacts.d.ts +24 -0
- package/dist/workflow/artifacts.js +45 -0
- package/dist/workflow/baton.d.ts +66 -0
- package/dist/workflow/baton.js +101 -0
- package/dist/workflow/context.d.ts +12 -0
- package/dist/workflow/context.js +67 -0
- package/dist/workflow/directives.d.ts +37 -0
- package/dist/workflow/directives.js +111 -0
- package/dist/workflow/repair.d.ts +8 -0
- package/dist/workflow/repair.js +99 -0
- package/dist/workflow/state-machine.d.ts +43 -0
- package/dist/workflow/state-machine.js +127 -0
- package/dist/workflow/story-helpers.d.ts +9 -0
- package/dist/workflow/story-helpers.js +13 -0
- package/package.json +32 -0
- package/src/agents/commands.ts +137 -0
- package/src/agents/prompts.ts +28 -0
- package/src/agents/registry.ts +310 -0
- package/src/agents/types.ts +31 -0
- package/src/config/config-handler.ts +48 -0
- package/src/config/defaults.ts +4 -0
- package/src/config/loader.ts +55 -0
- package/src/config/schema.ts +236 -0
- package/src/hooks/continuation-enforcer.ts +217 -0
- package/src/hooks/tool-output-truncator.ts +82 -0
- package/src/index.ts +131 -0
- package/src/shared/deep-merge.ts +28 -0
- package/src/shared/hash.ts +5 -0
- package/src/shared/log.ts +30 -0
- package/src/shared/model-tuning.ts +48 -0
- package/src/shared/paths.ts +70 -0
- package/src/shared/text.ts +20 -0
- package/src/shared/time.ts +3 -0
- package/src/shims.node.d.ts +20 -0
- package/src/state/adapters/index.ts +155 -0
- package/src/state/db.ts +105 -0
- package/src/state/ids.ts +33 -0
- package/src/state/schema.ts +249 -0
- package/src/state/types.ts +76 -0
- package/src/tools/artifacts.ts +83 -0
- package/src/tools/index.ts +111 -0
- package/src/tools/init.ts +50 -0
- package/src/tools/injects.ts +108 -0
- package/src/tools/repair.ts +31 -0
- package/src/tools/run.ts +62 -0
- package/src/tools/spec.ts +50 -0
- package/src/tools/stage.ts +361 -0
- package/src/tools/status.ts +119 -0
- package/src/tools/story.ts +106 -0
- package/src/tools/workflow.ts +241 -0
- package/src/ui/inject.ts +13 -0
- package/src/ui/toasts.ts +48 -0
- package/src/workflow/artifacts.ts +69 -0
- package/src/workflow/baton.ts +141 -0
- package/src/workflow/context.ts +86 -0
- package/src/workflow/directives.ts +170 -0
- package/src/workflow/repair.ts +138 -0
- package/src/workflow/state-machine.ts +194 -0
- package/src/workflow/story-helpers.ts +18 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const PermissionValueSchema = z.enum(["ask", "allow", "deny"]);
|
|
4
|
+
export type PermissionValue = z.infer<typeof PermissionValueSchema>;
|
|
5
|
+
|
|
6
|
+
const StageKeySchema = z.enum([
|
|
7
|
+
"frame",
|
|
8
|
+
"plan",
|
|
9
|
+
"spec",
|
|
10
|
+
"implement",
|
|
11
|
+
"review",
|
|
12
|
+
"verify",
|
|
13
|
+
"close",
|
|
14
|
+
]);
|
|
15
|
+
export type StageKey = z.infer<typeof StageKeySchema>;
|
|
16
|
+
|
|
17
|
+
const InjectionModeSchema = z.enum(["visible", "silent"]);
|
|
18
|
+
|
|
19
|
+
const TruncationPolicySchema = z
|
|
20
|
+
.object({
|
|
21
|
+
enabled: z.boolean().default(true),
|
|
22
|
+
truncate_all_tool_outputs: z.boolean().default(false),
|
|
23
|
+
max_chars_default: z.number().int().positive().default(200_000),
|
|
24
|
+
max_chars_webfetch: z.number().int().positive().default(40_000),
|
|
25
|
+
max_chars_diff: z.number().int().positive().default(120_000),
|
|
26
|
+
persist_truncated_outputs: z.boolean().default(true),
|
|
27
|
+
})
|
|
28
|
+
.partial()
|
|
29
|
+
.default({});
|
|
30
|
+
|
|
31
|
+
const ContextCompactionSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
enabled: z.boolean().default(true),
|
|
34
|
+
snapshot_after_stage_count: z.number().int().positive().default(2),
|
|
35
|
+
snapshot_max_lines: z.number().int().positive().default(60),
|
|
36
|
+
baton_summary_max_lines: z.number().int().positive().default(25),
|
|
37
|
+
inject_max_chars: z.number().int().positive().default(18_000),
|
|
38
|
+
})
|
|
39
|
+
.partial()
|
|
40
|
+
.default({});
|
|
41
|
+
|
|
42
|
+
const ContinuationSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
enabled: z.boolean().default(true),
|
|
45
|
+
injection_mode: InjectionModeSchema.default("visible"),
|
|
46
|
+
|
|
47
|
+
inject_on_session_idle: z.boolean().default(true),
|
|
48
|
+
session_idle_ms: z.number().int().positive().default(12_000),
|
|
49
|
+
|
|
50
|
+
inject_on_tool_done_if_run_active: z.boolean().default(true),
|
|
51
|
+
inject_on_message_done_if_run_active: z.boolean().default(true),
|
|
52
|
+
|
|
53
|
+
dedupe_window_ms: z.number().int().positive().default(20_000),
|
|
54
|
+
max_same_directive_repeats: z.number().int().positive().default(1),
|
|
55
|
+
|
|
56
|
+
auto_continue: z.boolean().default(false),
|
|
57
|
+
auto_continue_delay_ms: z.number().int().positive().default(1500),
|
|
58
|
+
max_auto_steps_per_session: z.number().int().positive().default(50),
|
|
59
|
+
})
|
|
60
|
+
.partial()
|
|
61
|
+
.default({});
|
|
62
|
+
|
|
63
|
+
const ToastsSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
enabled: z.boolean().default(true),
|
|
66
|
+
throttle_ms: z.number().int().positive().default(1500),
|
|
67
|
+
show_run_started: z.boolean().default(true),
|
|
68
|
+
show_stage_started: z.boolean().default(true),
|
|
69
|
+
show_stage_completed: z.boolean().default(true),
|
|
70
|
+
show_stage_failed: z.boolean().default(true),
|
|
71
|
+
show_run_completed: z.boolean().default(true),
|
|
72
|
+
show_auto_continue: z.boolean().default(true),
|
|
73
|
+
})
|
|
74
|
+
.partial()
|
|
75
|
+
.default({});
|
|
76
|
+
|
|
77
|
+
const DbSchema = z
|
|
78
|
+
.object({
|
|
79
|
+
path: z.string().default(".astro/astro.db"),
|
|
80
|
+
busy_timeout_ms: z.number().int().positive().default(5000),
|
|
81
|
+
pragmas: z
|
|
82
|
+
.object({
|
|
83
|
+
journal_mode: z.enum(["WAL", "DELETE"]).default("WAL"),
|
|
84
|
+
synchronous: z.enum(["NORMAL", "FULL", "OFF"]).default("NORMAL"),
|
|
85
|
+
foreign_keys: z.boolean().default(true),
|
|
86
|
+
temp_store: z.enum(["DEFAULT", "MEMORY", "FILE"]).default("MEMORY"),
|
|
87
|
+
})
|
|
88
|
+
// Why: Zod's .default({}) requires the *type* to accept an empty object.
|
|
89
|
+
// We still want runtime defaults for each key, so we make keys optional.
|
|
90
|
+
.partial()
|
|
91
|
+
.default({}),
|
|
92
|
+
schema_version_required: z.number().int().positive().default(2),
|
|
93
|
+
allow_auto_migrate: z.boolean().default(true),
|
|
94
|
+
fail_on_downgrade: z.boolean().default(true),
|
|
95
|
+
})
|
|
96
|
+
// Why: allow "db: {}" (or missing db) while still applying defaults.
|
|
97
|
+
.partial()
|
|
98
|
+
.default({});
|
|
99
|
+
|
|
100
|
+
const WorkflowSchema = z.object({
|
|
101
|
+
pipeline: z.array(StageKeySchema).default([
|
|
102
|
+
"frame",
|
|
103
|
+
"plan",
|
|
104
|
+
"spec",
|
|
105
|
+
"implement",
|
|
106
|
+
"review",
|
|
107
|
+
"verify",
|
|
108
|
+
"close",
|
|
109
|
+
]),
|
|
110
|
+
|
|
111
|
+
default_mode: z.enum(["step", "loop"]).default("step"),
|
|
112
|
+
default_max_steps: z.number().int().positive().default(1),
|
|
113
|
+
loop_max_steps_hard_cap: z.number().int().positive().default(200),
|
|
114
|
+
|
|
115
|
+
plan_max_tasks: z.number().int().positive().default(7),
|
|
116
|
+
plan_max_lines: z.number().int().positive().default(80),
|
|
117
|
+
|
|
118
|
+
forbid_prompt_narration: z.boolean().default(true),
|
|
119
|
+
single_active_run_per_repo: z.boolean().default(true),
|
|
120
|
+
lock_timeout_ms: z.number().int().positive().default(4000),
|
|
121
|
+
|
|
122
|
+
role_first_subagents: z.boolean().default(true),
|
|
123
|
+
|
|
124
|
+
evidence_required: z
|
|
125
|
+
.object({
|
|
126
|
+
verify: z.boolean().default(true),
|
|
127
|
+
implement: z.boolean().default(false),
|
|
128
|
+
})
|
|
129
|
+
// NOTE: We want callers to be able to omit the whole object ("{}")
|
|
130
|
+
// while still receiving per-field defaults at parse time.
|
|
131
|
+
.partial()
|
|
132
|
+
.default({}),
|
|
133
|
+
}).partial().default({});
|
|
134
|
+
|
|
135
|
+
const ArtifactsSchema = z.object({
|
|
136
|
+
root_dir: z.string().default(".astro"),
|
|
137
|
+
runs_dir: z.string().default(".astro/runs"),
|
|
138
|
+
spec_path: z.string().default(".astro/spec.md"),
|
|
139
|
+
|
|
140
|
+
write_full_baton_md: z.boolean().default(true),
|
|
141
|
+
write_baton_summary_md: z.boolean().default(true),
|
|
142
|
+
write_baton_output_json: z.boolean().default(true),
|
|
143
|
+
|
|
144
|
+
baton_filename: z.string().default("baton.md"),
|
|
145
|
+
baton_summary_filename: z.string().default("baton.summary.md"),
|
|
146
|
+
baton_json_filename: z.string().default("baton.json"),
|
|
147
|
+
}).partial().default({});
|
|
148
|
+
|
|
149
|
+
const AgentsSchema = z.object({
|
|
150
|
+
// Display name for the *primary* agent tab.
|
|
151
|
+
orchestrator_name: z.string().default("Astro"),
|
|
152
|
+
|
|
153
|
+
// Display names for the stage sub-agents.
|
|
154
|
+
stage_agent_names: z
|
|
155
|
+
.object({
|
|
156
|
+
frame: z.string().default("Astro — Frame"),
|
|
157
|
+
plan: z.string().default("Astro — Plan"),
|
|
158
|
+
spec: z.string().default("Astro — Spec"),
|
|
159
|
+
implement: z.string().default("Astro — Implement"),
|
|
160
|
+
review: z.string().default("Astro — Review"),
|
|
161
|
+
verify: z.string().default("Astro — Verify"),
|
|
162
|
+
close: z.string().default("Astro — Close"),
|
|
163
|
+
})
|
|
164
|
+
.partial()
|
|
165
|
+
.default({}),
|
|
166
|
+
|
|
167
|
+
librarian_name: z.string().default("Astro — Librarian"),
|
|
168
|
+
explore_name: z.string().default("Astro — Explore"),
|
|
169
|
+
|
|
170
|
+
agent_variant_overrides: z
|
|
171
|
+
.record(
|
|
172
|
+
z.string(),
|
|
173
|
+
z.object({
|
|
174
|
+
variant: z.string().optional(),
|
|
175
|
+
model: z.string().optional(),
|
|
176
|
+
})
|
|
177
|
+
)
|
|
178
|
+
.default({}),
|
|
179
|
+
}).partial().default({});
|
|
180
|
+
|
|
181
|
+
const PermissionsSchema = z.object({
|
|
182
|
+
enforce_task_tool_restrictions: z.boolean().default(true),
|
|
183
|
+
deny_delegate_task_in_subagents: z.boolean().default(true),
|
|
184
|
+
}).partial().default({});
|
|
185
|
+
|
|
186
|
+
const GitSchema = z.object({
|
|
187
|
+
enabled: z.boolean().default(true),
|
|
188
|
+
allow_dirty_start: z.boolean().default(true),
|
|
189
|
+
auto_branch: z.boolean().default(true),
|
|
190
|
+
branch_prefix: z.string().default("astro/"),
|
|
191
|
+
auto_commit: z.boolean().default(false),
|
|
192
|
+
commit_message_template: z.string().default("astro: {{story_key}} {{title}}"),
|
|
193
|
+
persist_diff_artifacts: z.boolean().default(true),
|
|
194
|
+
}).partial().default({});
|
|
195
|
+
|
|
196
|
+
const UiSchema = z
|
|
197
|
+
.object({
|
|
198
|
+
toasts: ToastsSchema,
|
|
199
|
+
continue_prompt: z
|
|
200
|
+
.object({
|
|
201
|
+
enabled: z.boolean().default(true),
|
|
202
|
+
mode: z.enum(["toast_button", "popup", "chat_only"]).default("toast_button"),
|
|
203
|
+
idle_prompt_ms: z.number().int().positive().default(20_000),
|
|
204
|
+
})
|
|
205
|
+
.partial()
|
|
206
|
+
.default({}),
|
|
207
|
+
})
|
|
208
|
+
.partial()
|
|
209
|
+
.default({});
|
|
210
|
+
|
|
211
|
+
export const AstrocodeConfigSchema = z.object({
|
|
212
|
+
disabled_hooks: z.array(z.string()).default([]),
|
|
213
|
+
disabled_agents: z.array(z.string()).default([]),
|
|
214
|
+
disabled_commands: z.array(z.string()).default([]),
|
|
215
|
+
|
|
216
|
+
determinism: z
|
|
217
|
+
.object({
|
|
218
|
+
mode: z.enum(["on", "off"]).default("on"),
|
|
219
|
+
strict_stage_order: z.boolean().default(true),
|
|
220
|
+
})
|
|
221
|
+
.partial()
|
|
222
|
+
.default({}),
|
|
223
|
+
|
|
224
|
+
db: DbSchema,
|
|
225
|
+
workflow: WorkflowSchema,
|
|
226
|
+
continuation: ContinuationSchema,
|
|
227
|
+
truncation: TruncationPolicySchema,
|
|
228
|
+
context_compaction: ContextCompactionSchema,
|
|
229
|
+
artifacts: ArtifactsSchema,
|
|
230
|
+
agents: AgentsSchema,
|
|
231
|
+
permissions: PermissionsSchema,
|
|
232
|
+
git: GitSchema,
|
|
233
|
+
ui: UiSchema,
|
|
234
|
+
}).partial().default({});
|
|
235
|
+
|
|
236
|
+
export type AstrocodeConfig = z.infer<typeof AstrocodeConfigSchema>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { AstrocodeConfig } from "../config/schema";
|
|
2
|
+
import type { SqliteDb } from "../state/db";
|
|
3
|
+
import { buildContextSnapshot } from "../workflow/context";
|
|
4
|
+
import { decideNextAction, getActiveRun } from "../workflow/state-machine";
|
|
5
|
+
import { buildContinueDirective, type BuiltDirective } from "../workflow/directives";
|
|
6
|
+
import { injectChatPrompt } from "../ui/inject";
|
|
7
|
+
import { nowISO } from "../shared/time";
|
|
8
|
+
import { createToastManager } from "../ui/toasts";
|
|
9
|
+
|
|
10
|
+
type SessionState = {
|
|
11
|
+
lastHash: string | null;
|
|
12
|
+
lastAtMs: number;
|
|
13
|
+
repeats: number;
|
|
14
|
+
autoSteps: number;
|
|
15
|
+
idleTimer: NodeJS.Timeout | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ToolExecuteAfterInput = {
|
|
19
|
+
tool: string;
|
|
20
|
+
sessionID?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ChatMessageInput = {
|
|
24
|
+
sessionID: string;
|
|
25
|
+
agent: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type EventInput = {
|
|
29
|
+
event: { type: string; properties: any };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function msFromIso(iso: string): number {
|
|
33
|
+
const t = Date.parse(iso);
|
|
34
|
+
return Number.isFinite(t) ? t : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createContinuationEnforcer(opts: {
|
|
38
|
+
ctx: any;
|
|
39
|
+
config: AstrocodeConfig;
|
|
40
|
+
db: SqliteDb;
|
|
41
|
+
}) {
|
|
42
|
+
const { ctx, config, db } = opts;
|
|
43
|
+
|
|
44
|
+
const toasts = createToastManager({ ctx, throttleMs: config.ui.toasts.throttle_ms });
|
|
45
|
+
|
|
46
|
+
const sessions = new Map<string, SessionState>();
|
|
47
|
+
|
|
48
|
+
function getState(sessionId: string): SessionState {
|
|
49
|
+
const cur = sessions.get(sessionId);
|
|
50
|
+
if (cur) return cur;
|
|
51
|
+
const state: SessionState = { lastHash: null, lastAtMs: 0, repeats: 0, autoSteps: 0, idleTimer: null };
|
|
52
|
+
sessions.set(sessionId, state);
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clearIdleTimer(sessionId: string) {
|
|
57
|
+
const s = getState(sessionId);
|
|
58
|
+
if (s.idleTimer) {
|
|
59
|
+
clearTimeout(s.idleTimer);
|
|
60
|
+
s.idleTimer = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function scheduleIdleInjection(sessionId: string) {
|
|
65
|
+
clearIdleTimer(sessionId);
|
|
66
|
+
if (!config.continuation.enabled) return;
|
|
67
|
+
if (!config.continuation.inject_on_session_idle) return;
|
|
68
|
+
|
|
69
|
+
const delay = config.continuation.session_idle_ms;
|
|
70
|
+
|
|
71
|
+
const s = getState(sessionId);
|
|
72
|
+
s.idleTimer = setTimeout(() => {
|
|
73
|
+
// Fire and forget
|
|
74
|
+
void maybeInjectContinue(sessionId, "idle_timer");
|
|
75
|
+
}, delay);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function shouldDedupe(sessionId: string, directive: BuiltDirective): boolean {
|
|
79
|
+
const s = getState(sessionId);
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
|
|
82
|
+
// Memory window
|
|
83
|
+
if (s.lastHash === directive.hash && now - s.lastAtMs < config.continuation.dedupe_window_ms) {
|
|
84
|
+
if (s.repeats >= config.continuation.max_same_directive_repeats) return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// DB window (durable)
|
|
88
|
+
const cutoff = new Date(now - config.continuation.dedupe_window_ms).toISOString();
|
|
89
|
+
const row = db
|
|
90
|
+
.prepare(
|
|
91
|
+
"SELECT COUNT(*) as c FROM continuations WHERE session_id=? AND directive_hash=? AND created_at > ?"
|
|
92
|
+
)
|
|
93
|
+
.get(sessionId, directive.hash, cutoff) as { c: number } | undefined;
|
|
94
|
+
|
|
95
|
+
if ((row?.c ?? 0) >= config.continuation.max_same_directive_repeats) return true;
|
|
96
|
+
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function recordContinuation(sessionId: string, runId: string | null, directive: BuiltDirective, reason: string) {
|
|
101
|
+
db.prepare(
|
|
102
|
+
"INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
103
|
+
).run(sessionId, runId, directive.hash, directive.kind, reason, nowISO());
|
|
104
|
+
|
|
105
|
+
const s = getState(sessionId);
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
if (s.lastHash === directive.hash && now - s.lastAtMs < config.continuation.dedupe_window_ms) {
|
|
108
|
+
s.repeats += 1;
|
|
109
|
+
} else {
|
|
110
|
+
s.lastHash = directive.hash;
|
|
111
|
+
s.repeats = 1;
|
|
112
|
+
}
|
|
113
|
+
s.lastAtMs = now;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatNextAction(next: ReturnType<typeof decideNextAction>): string {
|
|
117
|
+
switch (next.kind) {
|
|
118
|
+
case "idle":
|
|
119
|
+
return "No approved stories. Queue/approve a story.";
|
|
120
|
+
case "start_run":
|
|
121
|
+
return `Start run for story ${next.story_key}.`;
|
|
122
|
+
case "delegate_stage":
|
|
123
|
+
return `Delegate stage ${next.stage_key}.`;
|
|
124
|
+
case "await_stage_completion":
|
|
125
|
+
return `Await stage ${next.stage_key} completion. If you have stage output, call astro_stage_complete.`;
|
|
126
|
+
case "complete_run":
|
|
127
|
+
return `Complete run ${next.run_id}.`;
|
|
128
|
+
case "failed":
|
|
129
|
+
return `Run failed at stage ${next.stage_key}: ${next.error_text}`;
|
|
130
|
+
default:
|
|
131
|
+
return "Continue.";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function maybeInjectContinue(sessionId: string, reason: string) {
|
|
136
|
+
if (!config.continuation.enabled) return;
|
|
137
|
+
|
|
138
|
+
// Require active run
|
|
139
|
+
const active = getActiveRun(db);
|
|
140
|
+
if (!active) return;
|
|
141
|
+
|
|
142
|
+
const next = decideNextAction(db, config);
|
|
143
|
+
|
|
144
|
+
// If failed, don't auto-inject "continue" — surface via toast and stop.
|
|
145
|
+
if (next.kind === "failed") {
|
|
146
|
+
if (config.ui.toasts.enabled && config.ui.toasts.show_stage_failed) {
|
|
147
|
+
await toasts.show({ title: "Astrocode", message: `Run failed at ${next.stage_key}`, variant: "error" });
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const nextActionStr = formatNextAction(next);
|
|
153
|
+
const context = buildContextSnapshot({ db, config, run_id: active.run_id, next_action: nextActionStr });
|
|
154
|
+
|
|
155
|
+
const directive = buildContinueDirective({
|
|
156
|
+
config,
|
|
157
|
+
run_id: active.run_id,
|
|
158
|
+
stage_key: active.current_stage_key,
|
|
159
|
+
next_action: nextActionStr,
|
|
160
|
+
context_snapshot_md: context,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (shouldDedupe(sessionId, directive)) return;
|
|
164
|
+
|
|
165
|
+
await recordContinuation(sessionId, active.run_id, directive, reason);
|
|
166
|
+
|
|
167
|
+
// Injection mode
|
|
168
|
+
if (config.continuation.injection_mode === "visible") {
|
|
169
|
+
await injectChatPrompt({ ctx, sessionId, text: directive.body });
|
|
170
|
+
} else {
|
|
171
|
+
// Silent mode is TODO: requires experimental.chat.messages.transform.
|
|
172
|
+
// For v2-alpha, we fall back to visible injection but mark it.
|
|
173
|
+
await injectChatPrompt({ ctx, sessionId, text: directive.body + "\n\n(Injected in silent mode fallback)" });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (config.ui.toasts.enabled && config.ui.toasts.show_auto_continue) {
|
|
177
|
+
await toasts.show({ title: "Astrocode", message: "Continue directive injected", variant: "info" });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Public hook handlers
|
|
182
|
+
return {
|
|
183
|
+
async onToolAfter(input: ToolExecuteAfterInput) {
|
|
184
|
+
const sessionId = input.sessionID ?? (ctx as any).sessionID;
|
|
185
|
+
if (!sessionId) return;
|
|
186
|
+
if (!config.continuation.inject_on_tool_done_if_run_active) return;
|
|
187
|
+
|
|
188
|
+
scheduleIdleInjection(sessionId);
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async onChatMessage(_input: ChatMessageInput) {
|
|
192
|
+
if (!config.continuation.inject_on_message_done_if_run_active) return;
|
|
193
|
+
scheduleIdleInjection(_input.sessionID);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
async onEvent(input: EventInput) {
|
|
197
|
+
const type = input.event.type;
|
|
198
|
+
const sessionId = input.event.properties?.sessionID;
|
|
199
|
+
if (!sessionId) return;
|
|
200
|
+
|
|
201
|
+
if (type === "session.idle") {
|
|
202
|
+
if (!config.continuation.inject_on_session_idle) return;
|
|
203
|
+
await maybeInjectContinue(sessionId, "session.idle");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (type === "session.created") {
|
|
207
|
+
// When a session is created and there is an active run, nudge.
|
|
208
|
+
scheduleIdleInjection(sessionId);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (type === "session.deleted") {
|
|
212
|
+
clearIdleTimer(sessionId);
|
|
213
|
+
sessions.delete(sessionId);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { AstrocodeConfig } from "../config/schema";
|
|
3
|
+
import type { SqliteDb } from "../state/db";
|
|
4
|
+
import { getAstroPaths, runDir, ensureDir, toPosix } from "../shared/paths";
|
|
5
|
+
import { nowISO } from "../shared/time";
|
|
6
|
+
import { putArtifact } from "../workflow/artifacts";
|
|
7
|
+
import { sha256Hex } from "../shared/hash";
|
|
8
|
+
import { clampChars } from "../shared/text";
|
|
9
|
+
import { getActiveRun } from "../workflow/state-machine";
|
|
10
|
+
|
|
11
|
+
type ToolExecuteAfterInput = {
|
|
12
|
+
tool: string;
|
|
13
|
+
sessionID?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type ToolExecuteAfterOutput = {
|
|
17
|
+
title?: string;
|
|
18
|
+
output?: string;
|
|
19
|
+
metadata?: Record<string, any>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function pickMaxChars(toolName: string, cfg: AstrocodeConfig): number {
|
|
23
|
+
if (toolName.includes("webfetch") || toolName.includes("web.run")) return cfg.truncation.max_chars_webfetch;
|
|
24
|
+
if (toolName.includes("diff")) return cfg.truncation.max_chars_diff;
|
|
25
|
+
return cfg.truncation.max_chars_default;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createToolOutputTruncatorHook(opts: {
|
|
29
|
+
ctx: any;
|
|
30
|
+
config: AstrocodeConfig;
|
|
31
|
+
db: SqliteDb;
|
|
32
|
+
}) {
|
|
33
|
+
const { ctx, config, db } = opts;
|
|
34
|
+
|
|
35
|
+
return async function toolExecuteAfter(input: ToolExecuteAfterInput, output: ToolExecuteAfterOutput) {
|
|
36
|
+
if (!config.truncation.enabled) return;
|
|
37
|
+
|
|
38
|
+
const toolName = input.tool;
|
|
39
|
+
const text = output.output ?? "";
|
|
40
|
+
const maxChars = pickMaxChars(toolName, config);
|
|
41
|
+
|
|
42
|
+
if (!text || text.length <= maxChars) return;
|
|
43
|
+
|
|
44
|
+
const repoRoot = (ctx as any).directory as string;
|
|
45
|
+
const paths = getAstroPaths(repoRoot, config.db.path);
|
|
46
|
+
ensureDir(paths.toolOutputDir);
|
|
47
|
+
|
|
48
|
+
const active = getActiveRun(db);
|
|
49
|
+
const timestamp = nowISO().replace(/[:.]/g, "-");
|
|
50
|
+
const relBase =
|
|
51
|
+
active && config.truncation.persist_truncated_outputs
|
|
52
|
+
? toPosix(path.join(".astro", "runs", active.run_id, "_tool_output"))
|
|
53
|
+
: toPosix(path.join(".astro", "tool_output"));
|
|
54
|
+
|
|
55
|
+
const relPath = toPosix(path.join(relBase, toolName.replace(/[^a-zA-Z0-9_-]/g, "_"), `${timestamp}.txt`));
|
|
56
|
+
|
|
57
|
+
const { artifact_id } = putArtifact({
|
|
58
|
+
repoRoot,
|
|
59
|
+
db,
|
|
60
|
+
run_id: active?.run_id ?? null,
|
|
61
|
+
stage_key: active?.current_stage_key ?? null,
|
|
62
|
+
type: "tool_output",
|
|
63
|
+
rel_path: relPath,
|
|
64
|
+
content: text,
|
|
65
|
+
meta: { tool: toolName, session_id: input.sessionID ?? null },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const digest = sha256Hex(text);
|
|
69
|
+
const head = clampChars(text, Math.min(maxChars, 4000));
|
|
70
|
+
|
|
71
|
+
output.output =
|
|
72
|
+
head +
|
|
73
|
+
`\n\n…(truncated; sha256=${digest})\n` +
|
|
74
|
+
`Full output saved: ${relPath}\n` +
|
|
75
|
+
`Artifact ID: ${artifact_id}`;
|
|
76
|
+
|
|
77
|
+
output.metadata = output.metadata ?? {};
|
|
78
|
+
output.metadata.truncated = true;
|
|
79
|
+
output.metadata.artifact_id = artifact_id;
|
|
80
|
+
output.metadata.full_output_path = relPath;
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { loadAstrocodeConfig } from "./config/loader";
|
|
3
|
+
import { createConfigHandler } from "./config/config-handler";
|
|
4
|
+
import { openSqlite, configurePragmas, ensureSchema } from "./state/db";
|
|
5
|
+
import { getAstroPaths, ensureAstroDirs } from "./shared/paths";
|
|
6
|
+
import { createAstroTools } from "./tools";
|
|
7
|
+
import { createContinuationEnforcer } from "./hooks/continuation-enforcer";
|
|
8
|
+
import { createToolOutputTruncatorHook } from "./hooks/tool-output-truncator";
|
|
9
|
+
import { createToastManager } from "./ui/toasts";
|
|
10
|
+
import { info, warn } from "./shared/log";
|
|
11
|
+
|
|
12
|
+
console.log("Astrocode plugin loading...");
|
|
13
|
+
|
|
14
|
+
const Astrocode: Plugin = async (ctx) => {
|
|
15
|
+
const repoRoot = ctx.directory as string;
|
|
16
|
+
|
|
17
|
+
// Always load config first - this provides defaults even in limited mode
|
|
18
|
+
let pluginConfig = loadAstrocodeConfig(repoRoot);
|
|
19
|
+
|
|
20
|
+
// Always ensure .astro directories exist, even in limited mode
|
|
21
|
+
const paths = getAstroPaths(repoRoot, pluginConfig.db.path);
|
|
22
|
+
ensureAstroDirs(paths);
|
|
23
|
+
|
|
24
|
+
let db: any = null;
|
|
25
|
+
let tools: any = null;
|
|
26
|
+
let configHandler: any = null;
|
|
27
|
+
let continuation: any = null;
|
|
28
|
+
let truncatorHook: any = null;
|
|
29
|
+
let toasts: any = null;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
|
|
33
|
+
db = openSqlite(paths.dbPath, { busyTimeoutMs: pluginConfig.db.busy_timeout_ms });
|
|
34
|
+
configurePragmas(db, pluginConfig.db.pragmas);
|
|
35
|
+
ensureSchema(db, { allowAutoMigrate: pluginConfig.db.allow_auto_migrate, failOnDowngrade: pluginConfig.db.fail_on_downgrade });
|
|
36
|
+
|
|
37
|
+
// Database initialized successfully
|
|
38
|
+
configHandler = createConfigHandler({ pluginConfig });
|
|
39
|
+
tools = createAstroTools({ ctx, config: pluginConfig, db });
|
|
40
|
+
continuation = createContinuationEnforcer({ ctx, config: pluginConfig, db });
|
|
41
|
+
truncatorHook = createToolOutputTruncatorHook({ ctx, config: pluginConfig, db });
|
|
42
|
+
toasts = createToastManager({ ctx, throttleMs: pluginConfig.ui.toasts.throttle_ms });
|
|
43
|
+
} catch (e) {
|
|
44
|
+
// Database initialization failed - setup limited mode
|
|
45
|
+
|
|
46
|
+
// Reload config to ensure all defaults are present
|
|
47
|
+
pluginConfig = loadAstrocodeConfig(repoRoot);
|
|
48
|
+
|
|
49
|
+
// Modify config for limited mode
|
|
50
|
+
pluginConfig.disabled_hooks = [...(pluginConfig.disabled_hooks || []), "continuation-enforcer", "tool-output-truncator"];
|
|
51
|
+
pluginConfig.ui.toasts.enabled = false;
|
|
52
|
+
|
|
53
|
+
// Create limited functionality
|
|
54
|
+
db = null;
|
|
55
|
+
configHandler = createConfigHandler({ pluginConfig });
|
|
56
|
+
tools = createAstroTools({ ctx, config: pluginConfig, db });
|
|
57
|
+
continuation = null;
|
|
58
|
+
truncatorHook = null;
|
|
59
|
+
toasts = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: "Astrocode",
|
|
64
|
+
|
|
65
|
+
// Merge agents + slash commands into system config
|
|
66
|
+
config: configHandler,
|
|
67
|
+
|
|
68
|
+
// Register tools
|
|
69
|
+
tool: tools,
|
|
70
|
+
|
|
71
|
+
// Limit created subagents from spawning more subagents (OMO-style).
|
|
72
|
+
"tool.execute.before": async (input: any, output: any) => {
|
|
73
|
+
if (!pluginConfig.permissions.enforce_task_tool_restrictions) return;
|
|
74
|
+
if (input.tool !== "task") return;
|
|
75
|
+
|
|
76
|
+
output.args = output.args ?? {};
|
|
77
|
+
|
|
78
|
+
const toolsMap = { ...(output.args.tools ?? {}) };
|
|
79
|
+
|
|
80
|
+
if (pluginConfig.permissions.deny_delegate_task_in_subagents) {
|
|
81
|
+
toolsMap.delegate_task = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
output.args.tools = toolsMap;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
"tool.execute.after": async (input: any, output: any) => {
|
|
88
|
+
// Truncate huge tool outputs to artifacts
|
|
89
|
+
if (truncatorHook && !pluginConfig.disabled_hooks.includes("tool-output-truncator")) {
|
|
90
|
+
await truncatorHook(input, output);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Schedule continuation (do not immediately spam)
|
|
94
|
+
if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
|
|
95
|
+
await continuation.onToolAfter(input);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
"chat.message": async (input: any, output: any) => {
|
|
100
|
+
if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
|
|
101
|
+
await continuation.onChatMessage(input);
|
|
102
|
+
}
|
|
103
|
+
return output;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
event: async (input: any) => {
|
|
107
|
+
if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
|
|
108
|
+
await continuation.onEvent(input);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// Best-effort cleanup
|
|
113
|
+
close: async () => {
|
|
114
|
+
try {
|
|
115
|
+
db.close();
|
|
116
|
+
} catch {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (toasts && pluginConfig.ui.toasts.enabled) {
|
|
121
|
+
try {
|
|
122
|
+
await toasts.show({ title: "Astrocode", message: "Plugin closed", variant: "info" });
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export default Astrocode;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
2
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deep merge:
|
|
7
|
+
* - objects merge recursively
|
|
8
|
+
* - arrays replace
|
|
9
|
+
* - primitives overwrite
|
|
10
|
+
*/
|
|
11
|
+
export function deepMerge<T>(base: T, patch: Partial<T>): T {
|
|
12
|
+
if (!isPlainObject(base) || !isPlainObject(patch)) {
|
|
13
|
+
return (patch as T) ?? base;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
17
|
+
|
|
18
|
+
for (const [k, v] of Object.entries(patch as Record<string, unknown>)) {
|
|
19
|
+
const cur = out[k];
|
|
20
|
+
if (isPlainObject(cur) && isPlainObject(v)) {
|
|
21
|
+
out[k] = deepMerge(cur, v);
|
|
22
|
+
} else {
|
|
23
|
+
out[k] = v;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return out as T;
|
|
28
|
+
}
|