astrocode-workflow 0.3.0 → 0.3.2
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/index.js +6 -0
- package/dist/shared/metrics.d.ts +66 -0
- package/dist/shared/metrics.js +112 -0
- package/dist/src/agents/commands.d.ts +9 -0
- package/dist/src/agents/commands.js +121 -0
- package/dist/src/agents/prompts.d.ts +3 -0
- package/dist/src/agents/prompts.js +232 -0
- package/dist/src/agents/registry.d.ts +6 -0
- package/dist/src/agents/registry.js +242 -0
- package/dist/src/agents/types.d.ts +14 -0
- package/dist/src/agents/types.js +8 -0
- package/dist/src/config/config-handler.d.ts +4 -0
- package/dist/src/config/config-handler.js +46 -0
- package/dist/src/config/defaults.d.ts +3 -0
- package/dist/src/config/defaults.js +3 -0
- package/dist/src/config/loader.d.ts +11 -0
- package/dist/src/config/loader.js +82 -0
- package/dist/src/config/schema.d.ts +194 -0
- package/dist/src/config/schema.js +223 -0
- package/dist/src/hooks/continuation-enforcer.d.ts +34 -0
- package/dist/src/hooks/continuation-enforcer.js +190 -0
- package/dist/src/hooks/inject-provider.d.ts +22 -0
- package/dist/src/hooks/inject-provider.js +120 -0
- package/dist/src/hooks/tool-output-truncator.d.ts +25 -0
- package/dist/src/hooks/tool-output-truncator.js +57 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +308 -0
- package/dist/src/shared/deep-merge.d.ts +8 -0
- package/dist/src/shared/deep-merge.js +25 -0
- package/dist/src/shared/hash.d.ts +1 -0
- package/dist/src/shared/hash.js +4 -0
- package/dist/src/shared/log.d.ts +7 -0
- package/dist/src/shared/log.js +24 -0
- package/dist/src/shared/metrics.d.ts +66 -0
- package/dist/src/shared/metrics.js +112 -0
- package/dist/src/shared/model-tuning.d.ts +9 -0
- package/dist/src/shared/model-tuning.js +28 -0
- package/dist/src/shared/paths.d.ts +19 -0
- package/dist/src/shared/paths.js +64 -0
- package/dist/src/shared/text.d.ts +4 -0
- package/dist/src/shared/text.js +19 -0
- package/dist/src/shared/time.d.ts +1 -0
- package/dist/src/shared/time.js +3 -0
- package/dist/src/state/adapters/index.d.ts +41 -0
- package/dist/src/state/adapters/index.js +115 -0
- package/dist/src/state/db.d.ts +16 -0
- package/dist/src/state/db.js +225 -0
- package/dist/src/state/ids.d.ts +8 -0
- package/dist/src/state/ids.js +25 -0
- package/dist/src/state/repo-lock.d.ts +3 -0
- package/dist/src/state/repo-lock.js +29 -0
- package/dist/src/state/schema.d.ts +2 -0
- package/dist/src/state/schema.js +251 -0
- package/dist/src/state/types.d.ts +71 -0
- package/dist/src/state/types.js +1 -0
- package/dist/src/tools/artifacts.d.ts +18 -0
- package/dist/src/tools/artifacts.js +71 -0
- package/dist/src/tools/health.d.ts +8 -0
- package/dist/src/tools/health.js +119 -0
- package/dist/src/tools/index.d.ts +20 -0
- package/dist/src/tools/index.js +94 -0
- package/dist/src/tools/init.d.ts +17 -0
- package/dist/src/tools/init.js +96 -0
- package/dist/src/tools/injects.d.ts +53 -0
- package/dist/src/tools/injects.js +325 -0
- package/dist/src/tools/metrics.d.ts +7 -0
- package/dist/src/tools/metrics.js +61 -0
- package/dist/src/tools/repair.d.ts +8 -0
- package/dist/src/tools/repair.js +25 -0
- package/dist/src/tools/reset.d.ts +8 -0
- package/dist/src/tools/reset.js +92 -0
- package/dist/src/tools/run.d.ts +13 -0
- package/dist/src/tools/run.js +54 -0
- package/dist/src/tools/spec.d.ts +12 -0
- package/dist/src/tools/spec.js +44 -0
- package/dist/src/tools/stage.d.ts +23 -0
- package/dist/src/tools/stage.js +371 -0
- package/dist/src/tools/status.d.ts +8 -0
- package/dist/src/tools/status.js +125 -0
- package/dist/src/tools/story.d.ts +23 -0
- package/dist/src/tools/story.js +85 -0
- package/dist/src/tools/workflow.d.ts +13 -0
- package/dist/src/tools/workflow.js +355 -0
- package/dist/src/ui/inject.d.ts +12 -0
- package/dist/src/ui/inject.js +107 -0
- package/dist/src/ui/toasts.d.ts +13 -0
- package/dist/src/ui/toasts.js +39 -0
- package/dist/src/workflow/artifacts.d.ts +24 -0
- package/dist/src/workflow/artifacts.js +45 -0
- package/dist/src/workflow/baton.d.ts +72 -0
- package/dist/src/workflow/baton.js +166 -0
- package/dist/src/workflow/context.d.ts +20 -0
- package/dist/src/workflow/context.js +113 -0
- package/dist/src/workflow/directives.d.ts +39 -0
- package/dist/src/workflow/directives.js +137 -0
- package/dist/src/workflow/repair.d.ts +8 -0
- package/dist/src/workflow/repair.js +99 -0
- package/dist/src/workflow/state-machine.d.ts +86 -0
- package/dist/src/workflow/state-machine.js +216 -0
- package/dist/src/workflow/story-helpers.d.ts +9 -0
- package/dist/src/workflow/story-helpers.js +13 -0
- package/dist/state/db.d.ts +1 -0
- package/dist/state/db.js +9 -0
- package/dist/state/repo-lock.d.ts +3 -0
- package/dist/state/repo-lock.js +29 -0
- package/dist/test/integration/db-transactions.test.d.ts +1 -0
- package/dist/test/integration/db-transactions.test.js +126 -0
- package/dist/test/integration/injection-metrics.test.d.ts +1 -0
- package/dist/test/integration/injection-metrics.test.js +129 -0
- package/dist/tools/health.d.ts +8 -0
- package/dist/tools/health.js +119 -0
- package/dist/tools/index.js +9 -0
- package/dist/tools/metrics.d.ts +7 -0
- package/dist/tools/metrics.js +61 -0
- package/dist/tools/reset.d.ts +8 -0
- package/dist/tools/reset.js +92 -0
- package/dist/tools/workflow.js +210 -215
- package/dist/ui/inject.d.ts +6 -0
- package/dist/ui/inject.js +86 -67
- package/dist/workflow/state-machine.d.ts +32 -32
- package/dist/workflow/state-machine.js +85 -170
- package/package.json +6 -3
- package/src/index.ts +8 -0
- package/src/shared/metrics.ts +148 -0
- package/src/state/db.ts +10 -1
- package/src/state/repo-lock.ts +158 -0
- package/src/tools/health.ts +128 -0
- package/src/tools/index.ts +12 -3
- package/src/tools/init.ts +26 -14
- package/src/tools/metrics.ts +71 -0
- package/src/tools/repair.ts +21 -8
- package/src/tools/reset.ts +100 -0
- package/src/tools/stage.ts +12 -0
- package/src/tools/status.ts +17 -3
- package/src/tools/story.ts +41 -15
- package/src/tools/workflow.ts +123 -121
- package/src/ui/inject.ts +113 -79
- package/src/workflow/state-machine.ts +123 -227
- package/src/tools/workflow.ts.backup +0 -681
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const PermissionValueSchema = z.enum(["ask", "allow", "deny"]);
|
|
3
|
+
const StageKeySchema = z.enum([
|
|
4
|
+
"frame",
|
|
5
|
+
"plan",
|
|
6
|
+
"spec",
|
|
7
|
+
"implement",
|
|
8
|
+
"review",
|
|
9
|
+
"verify",
|
|
10
|
+
"close",
|
|
11
|
+
]);
|
|
12
|
+
const InjectionModeSchema = z.enum(["visible", "silent"]);
|
|
13
|
+
const TruncationPolicySchema = z
|
|
14
|
+
.object({
|
|
15
|
+
enabled: z.boolean().default(true),
|
|
16
|
+
truncate_all_tool_outputs: z.boolean().default(false),
|
|
17
|
+
max_chars_default: z.number().int().positive().default(200_000),
|
|
18
|
+
max_chars_webfetch: z.number().int().positive().default(40_000),
|
|
19
|
+
max_chars_diff: z.number().int().positive().default(120_000),
|
|
20
|
+
persist_truncated_outputs: z.boolean().default(true),
|
|
21
|
+
})
|
|
22
|
+
.partial()
|
|
23
|
+
.default({});
|
|
24
|
+
const ContextCompactionSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
enabled: z.boolean().default(true),
|
|
27
|
+
snapshot_after_stage_count: z.number().int().positive().default(2),
|
|
28
|
+
snapshot_max_lines: z.number().int().positive().default(60),
|
|
29
|
+
baton_summary_max_lines: z.number().int().positive().default(25),
|
|
30
|
+
inject_max_chars: z.number().int().positive().default(18_000),
|
|
31
|
+
})
|
|
32
|
+
.partial()
|
|
33
|
+
.default({});
|
|
34
|
+
const ContinuationSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
enabled: z.boolean().default(true),
|
|
37
|
+
injection_mode: InjectionModeSchema.default("visible"),
|
|
38
|
+
inject_on_session_idle: z.boolean().default(true),
|
|
39
|
+
session_idle_ms: z.number().int().positive().default(12_000),
|
|
40
|
+
inject_on_tool_done_if_run_active: z.boolean().default(true),
|
|
41
|
+
inject_on_message_done_if_run_active: z.boolean().default(true),
|
|
42
|
+
dedupe_window_ms: z.number().int().positive().default(20_000),
|
|
43
|
+
max_same_directive_repeats: z.number().int().positive().default(1),
|
|
44
|
+
auto_continue: z.boolean().default(false),
|
|
45
|
+
auto_continue_delay_ms: z.number().int().positive().default(1500),
|
|
46
|
+
max_auto_steps_per_session: z.number().int().positive().default(50),
|
|
47
|
+
})
|
|
48
|
+
.partial()
|
|
49
|
+
.default({});
|
|
50
|
+
const ToastsSchema = z
|
|
51
|
+
.object({
|
|
52
|
+
enabled: z.boolean().default(true),
|
|
53
|
+
throttle_ms: z.number().int().positive().default(1500),
|
|
54
|
+
show_run_started: z.boolean().default(true),
|
|
55
|
+
show_stage_started: z.boolean().default(true),
|
|
56
|
+
show_stage_completed: z.boolean().default(true),
|
|
57
|
+
show_stage_failed: z.boolean().default(true),
|
|
58
|
+
show_run_completed: z.boolean().default(true),
|
|
59
|
+
show_auto_continue: z.boolean().default(true),
|
|
60
|
+
})
|
|
61
|
+
.partial()
|
|
62
|
+
.default({});
|
|
63
|
+
const DbSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
path: z.string().default(".astro/astro.db"),
|
|
66
|
+
busy_timeout_ms: z.number().int().positive().default(5000),
|
|
67
|
+
pragmas: z
|
|
68
|
+
.object({
|
|
69
|
+
journal_mode: z.enum(["WAL", "DELETE"]).default("WAL"),
|
|
70
|
+
synchronous: z.enum(["NORMAL", "FULL", "OFF"]).default("NORMAL"),
|
|
71
|
+
foreign_keys: z.boolean().default(true),
|
|
72
|
+
temp_store: z.enum(["DEFAULT", "MEMORY", "FILE"]).default("MEMORY"),
|
|
73
|
+
})
|
|
74
|
+
// Why: Zod's .default({}) requires the *type* to accept an empty object.
|
|
75
|
+
// We still want runtime defaults for each key, so we make keys optional.
|
|
76
|
+
.partial()
|
|
77
|
+
.default({}),
|
|
78
|
+
schema_version_required: z.number().int().positive().default(2),
|
|
79
|
+
allow_auto_migrate: z.boolean().default(true),
|
|
80
|
+
fail_on_downgrade: z.boolean().default(true),
|
|
81
|
+
})
|
|
82
|
+
// Why: allow "db: {}" (or missing db) while still applying defaults.
|
|
83
|
+
.partial()
|
|
84
|
+
.default({});
|
|
85
|
+
const WorkflowSchema = z.object({
|
|
86
|
+
pipeline: z.array(StageKeySchema).default([
|
|
87
|
+
"frame",
|
|
88
|
+
"plan",
|
|
89
|
+
"spec",
|
|
90
|
+
"implement",
|
|
91
|
+
"review",
|
|
92
|
+
"verify",
|
|
93
|
+
"close",
|
|
94
|
+
]),
|
|
95
|
+
genesis_planning: z.enum(["off", "first_story_only", "always"]).default("first_story_only"),
|
|
96
|
+
default_mode: z.enum(["step", "loop"]).default("step"),
|
|
97
|
+
default_max_steps: z.number().int().positive().default(1),
|
|
98
|
+
loop_max_steps_hard_cap: z.number().int().positive().default(200),
|
|
99
|
+
plan_max_tasks: z.number().int().positive().default(500),
|
|
100
|
+
plan_max_lines: z.number().int().positive().default(2000),
|
|
101
|
+
baton_summary_max_lines: z.number().int().positive().default(20),
|
|
102
|
+
forbid_prompt_narration: z.boolean().default(true),
|
|
103
|
+
single_active_run_per_repo: z.boolean().default(true),
|
|
104
|
+
lock_timeout_ms: z.number().int().positive().default(4000),
|
|
105
|
+
role_first_subagents: z.boolean().default(true),
|
|
106
|
+
evidence_required: z
|
|
107
|
+
.object({
|
|
108
|
+
verify: z.boolean().default(true),
|
|
109
|
+
implement: z.boolean().default(false),
|
|
110
|
+
})
|
|
111
|
+
// NOTE: We want callers to be able to omit the whole object ("{}")
|
|
112
|
+
// while still receiving per-field defaults at parse time.
|
|
113
|
+
.partial()
|
|
114
|
+
.default({}),
|
|
115
|
+
}).partial().default({});
|
|
116
|
+
const ArtifactsSchema = z.object({
|
|
117
|
+
root_dir: z.string().default(".astro"),
|
|
118
|
+
runs_dir: z.string().default(".astro/runs"),
|
|
119
|
+
spec_path: z.string().default(".astro/spec.md"),
|
|
120
|
+
write_full_baton_md: z.boolean().default(true),
|
|
121
|
+
write_baton_summary_md: z.boolean().default(true),
|
|
122
|
+
write_baton_output_json: z.boolean().default(true),
|
|
123
|
+
baton_filename: z.string().default("baton.md"),
|
|
124
|
+
baton_summary_filename: z.string().default("baton.summary.md"),
|
|
125
|
+
baton_json_filename: z.string().default("baton.json"),
|
|
126
|
+
}).partial().default({});
|
|
127
|
+
const AgentsSchema = z.object({
|
|
128
|
+
// Display name for the *primary* agent tab.
|
|
129
|
+
orchestrator_name: z.string().default("Astro"),
|
|
130
|
+
// Display names for the stage sub-agents.
|
|
131
|
+
stage_agent_names: z
|
|
132
|
+
.object({
|
|
133
|
+
frame: z.string().default("Frame"),
|
|
134
|
+
plan: z.string().default("Plan"),
|
|
135
|
+
spec: z.string().default("Spec"),
|
|
136
|
+
implement: z.string().default("Implement"),
|
|
137
|
+
review: z.string().default("Review"),
|
|
138
|
+
verify: z.string().default("Verify"),
|
|
139
|
+
close: z.string().default("Close"),
|
|
140
|
+
})
|
|
141
|
+
.partial()
|
|
142
|
+
.default({}),
|
|
143
|
+
librarian_name: z.string().default("Librarian"),
|
|
144
|
+
explore_name: z.string().default("Explore"),
|
|
145
|
+
qa_name: z.string().default("QA"),
|
|
146
|
+
agent_variant_overrides: z
|
|
147
|
+
.record(z.string(), z.object({
|
|
148
|
+
variant: z.string().optional(),
|
|
149
|
+
model: z.string().optional(),
|
|
150
|
+
}))
|
|
151
|
+
.default({}),
|
|
152
|
+
}).partial().default({});
|
|
153
|
+
const PermissionsSchema = z.object({
|
|
154
|
+
enforce_task_tool_restrictions: z.boolean().default(true),
|
|
155
|
+
deny_delegate_task_in_subagents: z.boolean().default(true),
|
|
156
|
+
}).partial().default({});
|
|
157
|
+
const GitSchema = z.object({
|
|
158
|
+
enabled: z.boolean().default(true),
|
|
159
|
+
allow_dirty_start: z.boolean().default(true),
|
|
160
|
+
auto_branch: z.boolean().default(true),
|
|
161
|
+
branch_prefix: z.string().default("astro/"),
|
|
162
|
+
auto_commit: z.boolean().default(false),
|
|
163
|
+
commit_message_template: z.string().default("astro: {{story_key}} {{title}}"),
|
|
164
|
+
persist_diff_artifacts: z.boolean().default(true),
|
|
165
|
+
}).partial().default({});
|
|
166
|
+
const InjectSchema = z
|
|
167
|
+
.object({
|
|
168
|
+
enabled: z.boolean().default(true),
|
|
169
|
+
scope_allowlist: z.array(z.string()).default(["repo", "global"]),
|
|
170
|
+
type_allowlist: z.array(z.string()).default(["note", "policy"]),
|
|
171
|
+
max_per_turn: z.number().int().positive().default(5),
|
|
172
|
+
})
|
|
173
|
+
.partial()
|
|
174
|
+
.default({});
|
|
175
|
+
const DebugSchema = z
|
|
176
|
+
.object({
|
|
177
|
+
telemetry: z
|
|
178
|
+
.object({
|
|
179
|
+
enabled: z.boolean().default(false),
|
|
180
|
+
})
|
|
181
|
+
.partial()
|
|
182
|
+
.default({}),
|
|
183
|
+
})
|
|
184
|
+
.partial()
|
|
185
|
+
.default({});
|
|
186
|
+
const UiSchema = z
|
|
187
|
+
.object({
|
|
188
|
+
toasts: ToastsSchema,
|
|
189
|
+
continue_prompt: z
|
|
190
|
+
.object({
|
|
191
|
+
enabled: z.boolean().default(true),
|
|
192
|
+
mode: z.enum(["toast_button", "popup", "chat_only"]).default("toast_button"),
|
|
193
|
+
idle_prompt_ms: z.number().int().positive().default(20_000),
|
|
194
|
+
})
|
|
195
|
+
.partial()
|
|
196
|
+
.default({}),
|
|
197
|
+
})
|
|
198
|
+
.partial()
|
|
199
|
+
.default({});
|
|
200
|
+
export const AstrocodeConfigSchema = z.object({
|
|
201
|
+
disabled_hooks: z.array(z.string()).default([]),
|
|
202
|
+
disabled_agents: z.array(z.string()).default([]),
|
|
203
|
+
disabled_commands: z.array(z.string()).default([]),
|
|
204
|
+
determinism: z
|
|
205
|
+
.object({
|
|
206
|
+
mode: z.enum(["on", "off"]).default("on"),
|
|
207
|
+
strict_stage_order: z.boolean().default(true),
|
|
208
|
+
})
|
|
209
|
+
.partial()
|
|
210
|
+
.default({}),
|
|
211
|
+
db: DbSchema,
|
|
212
|
+
workflow: WorkflowSchema,
|
|
213
|
+
continuation: ContinuationSchema,
|
|
214
|
+
truncation: TruncationPolicySchema,
|
|
215
|
+
context_compaction: ContextCompactionSchema,
|
|
216
|
+
artifacts: ArtifactsSchema,
|
|
217
|
+
agents: AgentsSchema,
|
|
218
|
+
permissions: PermissionsSchema,
|
|
219
|
+
git: GitSchema,
|
|
220
|
+
ui: UiSchema,
|
|
221
|
+
inject: InjectSchema,
|
|
222
|
+
debug: DebugSchema,
|
|
223
|
+
}).partial().default({});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AstrocodeConfig } from "../config/schema";
|
|
2
|
+
import type { SqliteDb } from "../state/db";
|
|
3
|
+
type ToolExecuteAfterInput = {
|
|
4
|
+
tool: string;
|
|
5
|
+
sessionID?: string;
|
|
6
|
+
};
|
|
7
|
+
type ChatMessageInput = {
|
|
8
|
+
sessionID: string;
|
|
9
|
+
agent: string;
|
|
10
|
+
};
|
|
11
|
+
type EventInput = {
|
|
12
|
+
event: {
|
|
13
|
+
type: string;
|
|
14
|
+
properties: any;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
type RuntimeState = {
|
|
18
|
+
db: SqliteDb | null;
|
|
19
|
+
limitedMode: boolean;
|
|
20
|
+
limitedModeReason: null | {
|
|
21
|
+
code: "db_init_failed" | "schema_too_old" | "schema_downgrade" | "schema_migration_failed";
|
|
22
|
+
details: any;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
export declare function createContinuationEnforcer(opts: {
|
|
26
|
+
ctx: any;
|
|
27
|
+
config: AstrocodeConfig;
|
|
28
|
+
runtime: RuntimeState;
|
|
29
|
+
}): {
|
|
30
|
+
onToolAfter(input: ToolExecuteAfterInput): Promise<void>;
|
|
31
|
+
onChatMessage(_input: ChatMessageInput): Promise<void>;
|
|
32
|
+
onEvent(input: EventInput): Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { buildContextSnapshot } from "../workflow/context";
|
|
2
|
+
import { decideNextAction, getActiveRun } from "../workflow/state-machine";
|
|
3
|
+
import { buildContinueDirective } from "../workflow/directives";
|
|
4
|
+
import { injectChatPrompt } from "../ui/inject";
|
|
5
|
+
import { nowISO } from "../shared/time";
|
|
6
|
+
import { createToastManager } from "../ui/toasts";
|
|
7
|
+
function msFromIso(iso) {
|
|
8
|
+
const t = Date.parse(iso);
|
|
9
|
+
return Number.isFinite(t) ? t : 0;
|
|
10
|
+
}
|
|
11
|
+
export function createContinuationEnforcer(opts) {
|
|
12
|
+
const { ctx, config, runtime } = opts;
|
|
13
|
+
const { db } = runtime;
|
|
14
|
+
const toasts = createToastManager({ ctx, throttleMs: config.ui.toasts.throttle_ms });
|
|
15
|
+
const sessions = new Map();
|
|
16
|
+
function getState(sessionId) {
|
|
17
|
+
const cur = sessions.get(sessionId);
|
|
18
|
+
if (cur)
|
|
19
|
+
return cur;
|
|
20
|
+
const state = { lastHash: null, lastAtMs: 0, repeats: 0, autoSteps: 0, idleTimer: null, periodicTimer: null };
|
|
21
|
+
sessions.set(sessionId, state);
|
|
22
|
+
return state;
|
|
23
|
+
}
|
|
24
|
+
function clearIdleTimer(sessionId) {
|
|
25
|
+
const s = getState(sessionId);
|
|
26
|
+
if (s.idleTimer) {
|
|
27
|
+
clearTimeout(s.idleTimer);
|
|
28
|
+
s.idleTimer = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function clearPeriodicTimer(sessionId) {
|
|
32
|
+
const s = getState(sessionId);
|
|
33
|
+
if (s.periodicTimer) {
|
|
34
|
+
clearInterval(s.periodicTimer);
|
|
35
|
+
s.periodicTimer = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function scheduleIdleInjection(sessionId) {
|
|
39
|
+
clearIdleTimer(sessionId);
|
|
40
|
+
if (!config.continuation.enabled)
|
|
41
|
+
return;
|
|
42
|
+
if (!config.continuation.inject_on_session_idle)
|
|
43
|
+
return;
|
|
44
|
+
const delay = config.continuation.session_idle_ms;
|
|
45
|
+
const s = getState(sessionId);
|
|
46
|
+
s.idleTimer = setTimeout(() => {
|
|
47
|
+
// Fire and forget
|
|
48
|
+
void maybeInjectContinue(sessionId, "idle_timer");
|
|
49
|
+
}, delay);
|
|
50
|
+
}
|
|
51
|
+
function schedulePeriodicInjection(sessionId) {
|
|
52
|
+
clearPeriodicTimer(sessionId);
|
|
53
|
+
if (!config.continuation.enabled)
|
|
54
|
+
return;
|
|
55
|
+
// Inject every 3 minutes (180,000 ms)
|
|
56
|
+
const interval = 3 * 60 * 1000;
|
|
57
|
+
const s = getState(sessionId);
|
|
58
|
+
s.periodicTimer = setInterval(() => {
|
|
59
|
+
// Fire and forget
|
|
60
|
+
void maybeInjectContinue(sessionId, "periodic_timer");
|
|
61
|
+
}, interval);
|
|
62
|
+
}
|
|
63
|
+
function shouldDedupe(sessionId, directive) {
|
|
64
|
+
const s = getState(sessionId);
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
// Memory window
|
|
67
|
+
if (s.lastHash === directive.hash && now - s.lastAtMs < config.continuation.dedupe_window_ms) {
|
|
68
|
+
if (s.repeats >= config.continuation.max_same_directive_repeats)
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
// DB window (durable)
|
|
72
|
+
const cutoff = new Date(now - config.continuation.dedupe_window_ms).toISOString();
|
|
73
|
+
const row = db
|
|
74
|
+
.prepare("SELECT COUNT(*) as c FROM continuations WHERE session_id=? AND directive_hash=? AND created_at > ?")
|
|
75
|
+
.get(sessionId, directive.hash, cutoff);
|
|
76
|
+
if ((row?.c ?? 0) >= config.continuation.max_same_directive_repeats)
|
|
77
|
+
return true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
async function recordContinuation(sessionId, runId, directive, reason) {
|
|
81
|
+
db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(sessionId, runId, directive.hash, directive.kind, reason, nowISO());
|
|
82
|
+
const s = getState(sessionId);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
if (s.lastHash === directive.hash && now - s.lastAtMs < config.continuation.dedupe_window_ms) {
|
|
85
|
+
s.repeats += 1;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
s.lastHash = directive.hash;
|
|
89
|
+
s.repeats = 1;
|
|
90
|
+
}
|
|
91
|
+
s.lastAtMs = now;
|
|
92
|
+
}
|
|
93
|
+
function formatNextAction(next) {
|
|
94
|
+
switch (next.kind) {
|
|
95
|
+
case "idle":
|
|
96
|
+
return "No approved stories. Queue/approve a story.";
|
|
97
|
+
case "start_run":
|
|
98
|
+
return `Start run for story ${next.story_key}.`;
|
|
99
|
+
case "delegate_stage":
|
|
100
|
+
return `Delegate stage ${next.stage_key}.`;
|
|
101
|
+
case "await_stage_completion":
|
|
102
|
+
return `Await stage ${next.stage_key} completion. If you have stage output, call astro_stage_complete.`;
|
|
103
|
+
case "complete_run":
|
|
104
|
+
return `Complete run ${next.run_id}.`;
|
|
105
|
+
case "failed":
|
|
106
|
+
return `Run failed at stage ${next.stage_key}: ${next.error_text}`;
|
|
107
|
+
default:
|
|
108
|
+
return "Continue.";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function maybeInjectContinue(sessionId, reason) {
|
|
112
|
+
if (!config.continuation.enabled)
|
|
113
|
+
return;
|
|
114
|
+
// Require active run
|
|
115
|
+
const active = getActiveRun(db);
|
|
116
|
+
if (!active)
|
|
117
|
+
return;
|
|
118
|
+
const next = decideNextAction(db, config);
|
|
119
|
+
// If failed, don't auto-inject "continue" — surface via toast and stop.
|
|
120
|
+
if (next.kind === "failed") {
|
|
121
|
+
if (config.ui.toasts.enabled && config.ui.toasts.show_stage_failed) {
|
|
122
|
+
await toasts.show({ title: "Astrocode", message: `Run failed at ${next.stage_key}`, variant: "error" });
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const nextActionStr = formatNextAction(next);
|
|
127
|
+
const context = buildContextSnapshot({ db, config, run_id: active.run_id, next_action: nextActionStr });
|
|
128
|
+
const directive = buildContinueDirective({
|
|
129
|
+
config,
|
|
130
|
+
run_id: active.run_id,
|
|
131
|
+
stage_key: active.current_stage_key,
|
|
132
|
+
next_action: nextActionStr,
|
|
133
|
+
context_snapshot_md: context,
|
|
134
|
+
});
|
|
135
|
+
if (shouldDedupe(sessionId, directive))
|
|
136
|
+
return;
|
|
137
|
+
await recordContinuation(sessionId, active.run_id, directive, reason);
|
|
138
|
+
// Injection mode
|
|
139
|
+
if (config.continuation.injection_mode === "visible") {
|
|
140
|
+
await injectChatPrompt({ ctx, sessionId, text: directive.body, agent: "Astro" });
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Silent mode is TODO: requires experimental.chat.messages.transform.
|
|
144
|
+
// For v2-alpha, we fall back to visible injection but mark it.
|
|
145
|
+
await injectChatPrompt({ ctx, sessionId, text: directive.body + "\n\n(Injected in silent mode fallback)", agent: "Astro" });
|
|
146
|
+
}
|
|
147
|
+
if (config.ui.toasts.enabled && config.ui.toasts.show_auto_continue) {
|
|
148
|
+
await toasts.show({ title: "Astrocode", message: "Continue directive injected", variant: "info" });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Public hook handlers
|
|
152
|
+
return {
|
|
153
|
+
async onToolAfter(input) {
|
|
154
|
+
const sessionId = input.sessionID ?? ctx.sessionID;
|
|
155
|
+
if (!sessionId)
|
|
156
|
+
return;
|
|
157
|
+
if (!config.continuation.inject_on_tool_done_if_run_active)
|
|
158
|
+
return;
|
|
159
|
+
// Inject continuation immediately after any tool execution
|
|
160
|
+
void maybeInjectContinue(sessionId, "tool_execution");
|
|
161
|
+
scheduleIdleInjection(sessionId);
|
|
162
|
+
},
|
|
163
|
+
async onChatMessage(_input) {
|
|
164
|
+
if (!config.continuation.inject_on_message_done_if_run_active)
|
|
165
|
+
return;
|
|
166
|
+
scheduleIdleInjection(_input.sessionID);
|
|
167
|
+
},
|
|
168
|
+
async onEvent(input) {
|
|
169
|
+
const type = input.event.type;
|
|
170
|
+
const sessionId = input.event.properties?.sessionID;
|
|
171
|
+
if (!sessionId)
|
|
172
|
+
return;
|
|
173
|
+
if (type === "session.idle") {
|
|
174
|
+
if (!config.continuation.inject_on_session_idle)
|
|
175
|
+
return;
|
|
176
|
+
await maybeInjectContinue(sessionId, "session.idle");
|
|
177
|
+
}
|
|
178
|
+
if (type === "session.created") {
|
|
179
|
+
// When a session is created and there is an active run, nudge.
|
|
180
|
+
scheduleIdleInjection(sessionId);
|
|
181
|
+
schedulePeriodicInjection(sessionId);
|
|
182
|
+
}
|
|
183
|
+
if (type === "session.deleted") {
|
|
184
|
+
clearIdleTimer(sessionId);
|
|
185
|
+
clearPeriodicTimer(sessionId);
|
|
186
|
+
sessions.delete(sessionId);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AstrocodeConfig } from "../config/schema";
|
|
2
|
+
import type { SqliteDb } from "../state/db";
|
|
3
|
+
type ChatMessageInput = {
|
|
4
|
+
sessionID: string;
|
|
5
|
+
agent: string;
|
|
6
|
+
};
|
|
7
|
+
type RuntimeState = {
|
|
8
|
+
db: SqliteDb | null;
|
|
9
|
+
limitedMode: boolean;
|
|
10
|
+
limitedModeReason: null | {
|
|
11
|
+
code: "db_init_failed" | "schema_too_old" | "schema_downgrade" | "schema_migration_failed";
|
|
12
|
+
details: any;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export declare function createInjectProvider(opts: {
|
|
16
|
+
ctx: any;
|
|
17
|
+
config: AstrocodeConfig;
|
|
18
|
+
runtime: RuntimeState;
|
|
19
|
+
}): {
|
|
20
|
+
onChatMessage(input: ChatMessageInput): Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { selectEligibleInjects } from "../tools/injects";
|
|
2
|
+
import { injectChatPrompt } from "../ui/inject";
|
|
3
|
+
import { nowISO } from "../shared/time";
|
|
4
|
+
export function createInjectProvider(opts) {
|
|
5
|
+
const { ctx, config, runtime } = opts;
|
|
6
|
+
const { db } = runtime;
|
|
7
|
+
// Cache to avoid re-injecting the same injects repeatedly
|
|
8
|
+
const injectedCache = new Map();
|
|
9
|
+
function shouldSkipInject(injectId, nowMs) {
|
|
10
|
+
const lastInjected = injectedCache.get(injectId);
|
|
11
|
+
if (!lastInjected)
|
|
12
|
+
return false;
|
|
13
|
+
// Skip if injected within the last 5 minutes (configurable?)
|
|
14
|
+
const cooldownMs = 5 * 60 * 1000;
|
|
15
|
+
return nowMs - lastInjected < cooldownMs;
|
|
16
|
+
}
|
|
17
|
+
function markInjected(injectId, nowMs) {
|
|
18
|
+
injectedCache.set(injectId, nowMs);
|
|
19
|
+
}
|
|
20
|
+
function getInjectionDiagnostics(nowIso, scopeAllowlist, typeAllowlist) {
|
|
21
|
+
// Get ALL injects to analyze filtering
|
|
22
|
+
const allInjects = db.prepare("SELECT * FROM injects").all();
|
|
23
|
+
let total = allInjects.length;
|
|
24
|
+
let selected = 0;
|
|
25
|
+
let skippedExpired = 0;
|
|
26
|
+
let skippedScope = 0;
|
|
27
|
+
let skippedType = 0;
|
|
28
|
+
let eligibleIds = [];
|
|
29
|
+
for (const inject of allInjects) {
|
|
30
|
+
// Check expiration
|
|
31
|
+
if (inject.expires_at && inject.expires_at <= nowIso) {
|
|
32
|
+
skippedExpired++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// Check scope
|
|
36
|
+
if (!scopeAllowlist.includes(inject.scope)) {
|
|
37
|
+
skippedScope++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// Check type
|
|
41
|
+
if (!typeAllowlist.includes(inject.type)) {
|
|
42
|
+
skippedType++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// This inject is eligible
|
|
46
|
+
selected++;
|
|
47
|
+
eligibleIds.push(inject.inject_id);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
now: nowIso,
|
|
51
|
+
scopes_considered: scopeAllowlist,
|
|
52
|
+
types_considered: typeAllowlist,
|
|
53
|
+
total_injects: total,
|
|
54
|
+
selected_eligible: selected,
|
|
55
|
+
skipped: {
|
|
56
|
+
expired: skippedExpired,
|
|
57
|
+
scope: skippedScope,
|
|
58
|
+
type: skippedType,
|
|
59
|
+
},
|
|
60
|
+
eligible_ids: eligibleIds,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function injectEligibleInjects(sessionId) {
|
|
64
|
+
const now = nowISO();
|
|
65
|
+
const nowMs = Date.now();
|
|
66
|
+
// Get allowlists from config or defaults
|
|
67
|
+
const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
|
|
68
|
+
const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
|
|
69
|
+
const EMIT_TELEMETRY = config.debug?.telemetry?.enabled ?? false;
|
|
70
|
+
// Get diagnostic data
|
|
71
|
+
const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
|
|
72
|
+
const eligibleInjects = selectEligibleInjects(db, {
|
|
73
|
+
nowIso: now,
|
|
74
|
+
scopeAllowlist,
|
|
75
|
+
typeAllowlist,
|
|
76
|
+
limit: config.inject?.max_per_turn ?? 5,
|
|
77
|
+
});
|
|
78
|
+
let injected = 0;
|
|
79
|
+
let skippedDeduped = 0;
|
|
80
|
+
if (eligibleInjects.length === 0) {
|
|
81
|
+
// Log when no injects are eligible
|
|
82
|
+
if (EMIT_TELEMETRY) {
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Inject each eligible inject, skipping recently injected ones
|
|
89
|
+
for (const inject of eligibleInjects) {
|
|
90
|
+
if (shouldSkipInject(inject.inject_id, nowMs)) {
|
|
91
|
+
skippedDeduped++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Format as injection message
|
|
95
|
+
const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
|
|
96
|
+
await injectChatPrompt({
|
|
97
|
+
ctx,
|
|
98
|
+
sessionId,
|
|
99
|
+
text: formattedText,
|
|
100
|
+
agent: "Astrocode"
|
|
101
|
+
});
|
|
102
|
+
injected++;
|
|
103
|
+
markInjected(inject.inject_id, nowMs);
|
|
104
|
+
}
|
|
105
|
+
// Log diagnostic summary
|
|
106
|
+
if (EMIT_TELEMETRY) {
|
|
107
|
+
// eslint-disable-next-line no-console
|
|
108
|
+
console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Public hook handlers
|
|
112
|
+
return {
|
|
113
|
+
async onChatMessage(input) {
|
|
114
|
+
if (!config.inject?.enabled)
|
|
115
|
+
return;
|
|
116
|
+
// Inject eligible injects before processing the user's message
|
|
117
|
+
await injectEligibleInjects(input.sessionID);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AstrocodeConfig } from "../config/schema";
|
|
2
|
+
import type { SqliteDb } from "../state/db";
|
|
3
|
+
type ToolExecuteAfterInput = {
|
|
4
|
+
tool: string;
|
|
5
|
+
sessionID?: string;
|
|
6
|
+
};
|
|
7
|
+
type ToolExecuteAfterOutput = {
|
|
8
|
+
title?: string;
|
|
9
|
+
output?: string;
|
|
10
|
+
metadata?: Record<string, any>;
|
|
11
|
+
};
|
|
12
|
+
type RuntimeState = {
|
|
13
|
+
db: SqliteDb | null;
|
|
14
|
+
limitedMode: boolean;
|
|
15
|
+
limitedModeReason: null | {
|
|
16
|
+
code: "db_init_failed" | "schema_too_old" | "schema_downgrade" | "schema_migration_failed";
|
|
17
|
+
details: any;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export declare function createToolOutputTruncatorHook(opts: {
|
|
21
|
+
ctx: any;
|
|
22
|
+
config: AstrocodeConfig;
|
|
23
|
+
runtime: RuntimeState;
|
|
24
|
+
}): (input: ToolExecuteAfterInput, output: ToolExecuteAfterOutput) => Promise<void>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getAstroPaths, ensureDir, toPosix } from "../shared/paths";
|
|
3
|
+
import { nowISO } from "../shared/time";
|
|
4
|
+
import { putArtifact } from "../workflow/artifacts";
|
|
5
|
+
import { sha256Hex } from "../shared/hash";
|
|
6
|
+
import { clampChars } from "../shared/text";
|
|
7
|
+
import { getActiveRun } from "../workflow/state-machine";
|
|
8
|
+
function pickMaxChars(toolName, cfg) {
|
|
9
|
+
if (toolName.includes("webfetch") || toolName.includes("web.run"))
|
|
10
|
+
return cfg.truncation.max_chars_webfetch;
|
|
11
|
+
if (toolName.includes("diff"))
|
|
12
|
+
return cfg.truncation.max_chars_diff;
|
|
13
|
+
return cfg.truncation.max_chars_default;
|
|
14
|
+
}
|
|
15
|
+
export function createToolOutputTruncatorHook(opts) {
|
|
16
|
+
const { ctx, config, runtime } = opts;
|
|
17
|
+
const { db } = runtime;
|
|
18
|
+
return async function toolExecuteAfter(input, output) {
|
|
19
|
+
if (!config.truncation.enabled)
|
|
20
|
+
return;
|
|
21
|
+
const toolName = input.tool;
|
|
22
|
+
const text = output.output ?? "";
|
|
23
|
+
const maxChars = pickMaxChars(toolName, config);
|
|
24
|
+
if (!text || text.length <= maxChars)
|
|
25
|
+
return;
|
|
26
|
+
const repoRoot = ctx.directory;
|
|
27
|
+
const paths = getAstroPaths(repoRoot, config.db.path);
|
|
28
|
+
ensureDir(paths.toolOutputDir);
|
|
29
|
+
const active = getActiveRun(db);
|
|
30
|
+
const timestamp = nowISO().replace(/[:.]/g, "-");
|
|
31
|
+
const relBase = active && config.truncation.persist_truncated_outputs
|
|
32
|
+
? toPosix(path.join(".astro", "runs", active.run_id, "_tool_output"))
|
|
33
|
+
: toPosix(path.join(".astro", "tool_output"));
|
|
34
|
+
const relPath = toPosix(path.join(relBase, toolName.replace(/[^a-zA-Z0-9_-]/g, "_"), `${timestamp}.txt`));
|
|
35
|
+
const { artifact_id } = putArtifact({
|
|
36
|
+
repoRoot,
|
|
37
|
+
db,
|
|
38
|
+
run_id: active?.run_id ?? null,
|
|
39
|
+
stage_key: active?.current_stage_key ?? null,
|
|
40
|
+
type: "tool_output",
|
|
41
|
+
rel_path: relPath,
|
|
42
|
+
content: text,
|
|
43
|
+
meta: { tool: toolName, session_id: input.sessionID ?? null },
|
|
44
|
+
});
|
|
45
|
+
const digest = sha256Hex(text);
|
|
46
|
+
const head = clampChars(text, Math.min(maxChars, 4000));
|
|
47
|
+
output.output =
|
|
48
|
+
head +
|
|
49
|
+
`\n\n…(truncated; sha256=${digest})\n` +
|
|
50
|
+
`Full output saved: ${relPath}\n` +
|
|
51
|
+
`Artifact ID: ${artifact_id}`;
|
|
52
|
+
output.metadata = output.metadata ?? {};
|
|
53
|
+
output.metadata.truncated = true;
|
|
54
|
+
output.metadata.artifact_id = artifact_id;
|
|
55
|
+
output.metadata.full_output_path = relPath;
|
|
56
|
+
};
|
|
57
|
+
}
|