@openshain/core 0.1.1 → 0.3.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.
Files changed (58) hide show
  1. package/dist/config/load.d.ts +10 -0
  2. package/dist/config/load.js +66 -0
  3. package/dist/config/schema.d.ts +87 -0
  4. package/dist/config/schema.js +100 -0
  5. package/dist/errors.d.ts +10 -0
  6. package/dist/errors.js +30 -0
  7. package/dist/ids.d.ts +11 -0
  8. package/dist/ids.js +21 -0
  9. package/dist/index.d.ts +23 -0
  10. package/dist/index.js +22 -0
  11. package/dist/model/types.d.ts +57 -0
  12. package/dist/model/types.js +0 -0
  13. package/dist/runtime.d.ts +46 -0
  14. package/dist/runtime.js +147 -0
  15. package/dist/schemas.d.ts +9 -0
  16. package/dist/schemas.js +44 -0
  17. package/dist/tool/ask-user.d.ts +5 -0
  18. package/dist/tool/ask-user.js +22 -0
  19. package/dist/tool/load-module.d.ts +6 -0
  20. package/dist/tool/load-module.js +42 -0
  21. package/dist/tool/paths.d.ts +17 -0
  22. package/dist/tool/paths.js +82 -0
  23. package/dist/tool/registry.d.ts +30 -0
  24. package/dist/tool/registry.js +68 -0
  25. package/dist/tool/types.d.ts +44 -0
  26. package/dist/tool/types.js +17 -0
  27. package/dist/tool/validate.d.ts +14 -0
  28. package/dist/tool/validate.js +68 -0
  29. package/dist/uuid.d.ts +1 -0
  30. package/dist/uuid.js +33 -0
  31. package/dist/work/artifacts.d.ts +7 -0
  32. package/dist/work/artifacts.js +20 -0
  33. package/dist/work/event-log.d.ts +28 -0
  34. package/dist/work/event-log.js +140 -0
  35. package/dist/work/events.d.ts +329 -0
  36. package/dist/work/events.js +360 -0
  37. package/dist/work/history.d.ts +38 -0
  38. package/dist/work/history.js +70 -0
  39. package/dist/work/lock.d.ts +13 -0
  40. package/dist/work/lock.js +80 -0
  41. package/dist/work/projection.d.ts +31 -0
  42. package/dist/work/projection.js +133 -0
  43. package/dist/work/store.d.ts +58 -0
  44. package/dist/work/store.js +174 -0
  45. package/dist/work/work.d.ts +86 -0
  46. package/dist/work/work.js +149 -0
  47. package/package.json +15 -4
  48. package/src/config/load.ts +1 -1
  49. package/src/config/schema.ts +41 -31
  50. package/src/ids.ts +3 -2
  51. package/src/index.ts +14 -1
  52. package/src/runtime.ts +8 -2
  53. package/src/tool/ask-user.ts +25 -0
  54. package/src/tool/types.ts +2 -0
  55. package/src/uuid.ts +33 -0
  56. package/src/work/events.ts +20 -0
  57. package/src/work/history.ts +96 -0
  58. package/src/work/projection.ts +4 -1
@@ -0,0 +1,360 @@
1
+ import { z } from "zod";
2
+ import { OpenshainError } from "../errors.js";
3
+ export const TOOL_REJECTION_CODES = [
4
+ "schema_mismatch",
5
+ "unknown_tool",
6
+ "not_allowed",
7
+ "reserved_path",
8
+ "outside_workspace",
9
+ "invalid_path",
10
+ "limit_reached",
11
+ ];
12
+ // ---------------------------------------------------------------------------
13
+ // File-side schemas (snake_case). These are the on-disk contract.
14
+ // ---------------------------------------------------------------------------
15
+ // Payload schemas are loose: a field added by a newer runtime must not make an
16
+ // older runtime refuse the log. Only the envelope is strict; envelope changes bump `v`.
17
+ const textPart = z.looseObject({ type: z.literal("text"), text: z.string() });
18
+ const toolCallPart = z.looseObject({
19
+ type: z.literal("tool_call"),
20
+ id: z.string(),
21
+ name: z.string(),
22
+ input: z.unknown(),
23
+ });
24
+ const opaquePart = z.looseObject({
25
+ type: z.literal("opaque"),
26
+ provider: z.string(),
27
+ data: z.unknown(),
28
+ });
29
+ const jsonPart = z.looseObject({ type: z.literal("json"), value: z.unknown() });
30
+ const artifact = z.looseObject({
31
+ path: z.string(),
32
+ sha256: z.string(),
33
+ missing: z.literal(true).optional(),
34
+ claimed: z.literal(true).optional(),
35
+ });
36
+ const modelUsageFile = z.looseObject({
37
+ input_tokens: z.int().nonnegative(),
38
+ output_tokens: z.int().nonnegative(),
39
+ cached_input_tokens: z.int().nonnegative().optional(),
40
+ cache_write_tokens: z.int().nonnegative().optional(),
41
+ reasoning_tokens: z.int().nonnegative().optional(),
42
+ });
43
+ export const payloadFileSchemas = {
44
+ "work.created": z.looseObject({
45
+ objective: z.string(),
46
+ principal: z.string(),
47
+ profession: z.string(),
48
+ type: z.string(),
49
+ parent: z.string().optional(),
50
+ agent_name: z.string().optional(),
51
+ }),
52
+ "work.status_changed": z.looseObject({ from: z.string(), to: z.string(), reason: z.string() }),
53
+ "model.requested": z.looseObject({
54
+ provider: z.string(),
55
+ model: z.string(),
56
+ message_count: z.int().nonnegative(),
57
+ tool_names: z.array(z.string()),
58
+ }),
59
+ "model.completed": z.looseObject({
60
+ stop_reason: z.enum(["end_turn", "tool_call", "max_tokens", "refusal", "other"]),
61
+ content: z.array(z.discriminatedUnion("type", [textPart, toolCallPart, opaquePart])),
62
+ raw: z.unknown().optional(),
63
+ }),
64
+ "model.failed": z.looseObject({ code: z.string(), message: z.string() }),
65
+ "tool.called": z.looseObject({
66
+ call_id: z.string(),
67
+ provider: z.string(),
68
+ name: z.string(),
69
+ input: z.unknown(),
70
+ }),
71
+ "tool.completed": z.looseObject({
72
+ call_id: z.string(),
73
+ content: z.array(z.discriminatedUnion("type", [textPart, jsonPart])),
74
+ is_error: z.boolean(),
75
+ observation: z.looseObject({ source: z.string(), retrieved_at: z.iso.datetime() }).optional(),
76
+ after: z.array(artifact).optional(),
77
+ }),
78
+ "tool.rejected": z.looseObject({
79
+ call_id: z.string(),
80
+ name: z.string(),
81
+ code: z.enum(TOOL_REJECTION_CODES),
82
+ reason: z.string(),
83
+ }),
84
+ "human.input_requested": z.looseObject({ call_id: z.string(), question: z.string() }),
85
+ "human.input_provided": z.looseObject({ call_id: z.string(), answer: z.string() }),
86
+ "human.message": z.looseObject({ text: z.string() }),
87
+ "prompt.expanded": z.looseObject({ name: z.string(), source: z.string(), text: z.string() }),
88
+ "usage.recorded": z.discriminatedUnion("kind", [
89
+ z.looseObject({
90
+ kind: z.literal("model_inference"),
91
+ provider: z.string(),
92
+ model: z.string(),
93
+ usage: modelUsageFile,
94
+ }),
95
+ z.looseObject({
96
+ kind: z.literal("tool_execution"),
97
+ provider: z.string(),
98
+ usage: z.looseObject({ duration_ms: z.int().nonnegative() }),
99
+ }),
100
+ ]),
101
+ "evidence.recorded": z.looseObject({
102
+ claim: z.string(),
103
+ refs: z.array(z.string()),
104
+ artifacts: z.array(artifact),
105
+ }),
106
+ "work.completed": z.looseObject({ summary: z.string() }),
107
+ "work.failed": z.looseObject({ reason: z.string(), detail: z.string() }),
108
+ };
109
+ export const EventFileSchema = z.strictObject({
110
+ v: z.literal(1),
111
+ id: z.string(),
112
+ work_id: z.string(),
113
+ seq: z.int().positive(),
114
+ type: z.string(),
115
+ occurred_at: z.iso.datetime(),
116
+ recorded_at: z.iso.datetime(),
117
+ payload: z.unknown(),
118
+ });
119
+ // ---------------------------------------------------------------------------
120
+ // Mapping. The only place where snake_case and camelCase meet.
121
+ // ---------------------------------------------------------------------------
122
+ export function eventToFile(event) {
123
+ const payload = isKnownType(event.type)
124
+ ? payloadToFile(event.type, event.payload)
125
+ : event.payload;
126
+ return {
127
+ v: event.v,
128
+ id: event.id,
129
+ work_id: event.workId,
130
+ seq: event.seq,
131
+ type: event.type,
132
+ occurred_at: event.occurredAt,
133
+ recorded_at: event.recordedAt,
134
+ payload: canonical(payload),
135
+ };
136
+ }
137
+ const DATA_KEYS = new Set(["input", "data", "value", "raw"]);
138
+ /**
139
+ * Canonical JSON form: object keys sorted recursively so that equal data is
140
+ * written as equal bytes. Inside the free-form fields (`input`, `data`, `value`,
141
+ * `raw`) `undefined` becomes `null`, because JSON has no `undefined` and a
142
+ * dropped key would make the line unreadable.
143
+ */
144
+ export function canonical(value, insideData = false, seen = new WeakSet()) {
145
+ if (value === undefined)
146
+ return insideData ? null : undefined;
147
+ if (value === null || typeof value !== "object")
148
+ return value;
149
+ if (seen.has(value)) {
150
+ throw new OpenshainError("invalid_event", "a value that refers to itself cannot be recorded");
151
+ }
152
+ seen.add(value);
153
+ try {
154
+ if (Array.isArray(value))
155
+ return value.map((item) => canonical(item, insideData, seen) ?? null);
156
+ const out = {};
157
+ for (const key of Object.keys(value).sort()) {
158
+ const inner = insideData || DATA_KEYS.has(key);
159
+ const item = canonical(value[key], inner, seen);
160
+ if (item !== undefined)
161
+ define(out, key, item);
162
+ else if (inner)
163
+ define(out, key, null);
164
+ }
165
+ return out;
166
+ }
167
+ finally {
168
+ seen.delete(value);
169
+ }
170
+ }
171
+ /** Sets an own property even for a key such as `__proto__`, which plain assignment would treat as the prototype. */
172
+ function define(target, key, item) {
173
+ Object.defineProperty(target, key, {
174
+ value: item,
175
+ enumerable: true,
176
+ writable: true,
177
+ configurable: true,
178
+ });
179
+ }
180
+ export function eventFromFile(input) {
181
+ const parsed = EventFileSchema.safeParse(input);
182
+ if (!parsed.success) {
183
+ throw new OpenshainError("corrupt_log", `event envelope: ${describeIssues(parsed.error)}`);
184
+ }
185
+ const file = parsed.data;
186
+ const envelope = {
187
+ v: file.v,
188
+ id: file.id,
189
+ workId: file.work_id,
190
+ seq: file.seq,
191
+ occurredAt: file.occurred_at,
192
+ recordedAt: file.recorded_at,
193
+ };
194
+ if (!isKnownType(file.type)) {
195
+ return { ...envelope, type: file.type, payload: file.payload };
196
+ }
197
+ const payload = payloadFileSchemas[file.type].safeParse(file.payload);
198
+ if (!payload.success) {
199
+ throw new OpenshainError("corrupt_log", `${file.type} payload: ${describeIssues(payload.error)}`);
200
+ }
201
+ return {
202
+ ...envelope,
203
+ type: file.type,
204
+ payload: payloadFromFile(file.type, payload.data),
205
+ };
206
+ }
207
+ /**
208
+ * Validates a payload given in the file form (snake_case, as in spec/schemas/events.v1.json) and
209
+ * returns it in the in-memory form. For events a client hands the runtime to record.
210
+ */
211
+ export function parsePayloadFile(type, payload) {
212
+ const parsed = payloadFileSchemas[type].safeParse(payload);
213
+ if (!parsed.success) {
214
+ throw new OpenshainError("invalid_event", `${type} payload: ${describeIssues(parsed.error)}`);
215
+ }
216
+ return payloadFromFile(type, parsed.data);
217
+ }
218
+ export function isKnownEventType(type) {
219
+ return isKnownType(type);
220
+ }
221
+ function isKnownType(type) {
222
+ return Object.hasOwn(payloadFileSchemas, type);
223
+ }
224
+ function describeIssues(error) {
225
+ return error.issues
226
+ .map((issue) => (issue.path.length ? `${issue.path.map(String).join(".")}: ` : "") + issue.message)
227
+ .join("; ");
228
+ }
229
+ function usageToFile(usage) {
230
+ const out = {
231
+ input_tokens: usage.inputTokens,
232
+ output_tokens: usage.outputTokens,
233
+ };
234
+ if (usage.cachedInputTokens !== undefined)
235
+ out.cached_input_tokens = usage.cachedInputTokens;
236
+ if (usage.cacheWriteTokens !== undefined)
237
+ out.cache_write_tokens = usage.cacheWriteTokens;
238
+ if (usage.reasoningTokens !== undefined)
239
+ out.reasoning_tokens = usage.reasoningTokens;
240
+ return out;
241
+ }
242
+ function usageFromFile(usage) {
243
+ const out = { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens };
244
+ if (usage.cached_input_tokens !== undefined)
245
+ out.cachedInputTokens = usage.cached_input_tokens;
246
+ if (usage.cache_write_tokens !== undefined)
247
+ out.cacheWriteTokens = usage.cache_write_tokens;
248
+ if (usage.reasoning_tokens !== undefined)
249
+ out.reasoningTokens = usage.reasoning_tokens;
250
+ return out;
251
+ }
252
+ /**
253
+ * Event types whose field names differ between code and file. Every other
254
+ * payload uses the same names on both sides and needs no codec.
255
+ */
256
+ const codecs = {
257
+ "work.created": {
258
+ // Only agent_name changes its name; every other field, known or not, passes through.
259
+ toFile: ({ agentName, ...rest }) => ({
260
+ ...rest,
261
+ ...(agentName !== undefined && { agent_name: agentName }),
262
+ }),
263
+ fromFile: ({ agent_name, parent, ...rest }) => ({
264
+ ...rest,
265
+ ...(parent !== undefined && { parent }),
266
+ ...(agent_name !== undefined && { agentName: agent_name }),
267
+ }),
268
+ },
269
+ "model.requested": {
270
+ toFile: (p) => ({
271
+ provider: p.provider,
272
+ model: p.model,
273
+ message_count: p.messageCount,
274
+ tool_names: p.toolNames,
275
+ }),
276
+ fromFile: (p) => ({
277
+ provider: p.provider,
278
+ model: p.model,
279
+ messageCount: p.message_count,
280
+ toolNames: p.tool_names,
281
+ }),
282
+ },
283
+ "model.completed": {
284
+ toFile: (p) => {
285
+ const out = { stop_reason: p.stopReason, content: p.content };
286
+ if (p.raw !== undefined)
287
+ out.raw = p.raw;
288
+ return out;
289
+ },
290
+ fromFile: (p) => {
291
+ const out = {
292
+ stopReason: p.stop_reason,
293
+ content: p.content,
294
+ };
295
+ if (p.raw !== undefined)
296
+ out.raw = p.raw;
297
+ return out;
298
+ },
299
+ },
300
+ "tool.called": {
301
+ toFile: (p) => ({ call_id: p.callId, provider: p.provider, name: p.name, input: p.input }),
302
+ fromFile: (p) => ({ callId: p.call_id, provider: p.provider, name: p.name, input: p.input }),
303
+ },
304
+ "tool.completed": {
305
+ toFile: (p) => {
306
+ const out = {
307
+ call_id: p.callId,
308
+ content: p.content,
309
+ is_error: p.isError,
310
+ };
311
+ if (p.observation) {
312
+ out.observation = { source: p.observation.source, retrieved_at: p.observation.retrievedAt };
313
+ }
314
+ if (p.after)
315
+ out.after = p.after;
316
+ return out;
317
+ },
318
+ fromFile: (p) => {
319
+ const out = {
320
+ callId: p.call_id,
321
+ content: p.content,
322
+ isError: p.is_error,
323
+ };
324
+ if (p.observation) {
325
+ out.observation = { source: p.observation.source, retrievedAt: p.observation.retrieved_at };
326
+ }
327
+ if (p.after)
328
+ out.after = p.after.map((a) => ({ path: a.path, sha256: a.sha256 }));
329
+ return out;
330
+ },
331
+ },
332
+ "tool.rejected": {
333
+ toFile: (p) => ({ call_id: p.callId, name: p.name, code: p.code, reason: p.reason }),
334
+ fromFile: (p) => ({ callId: p.call_id, name: p.name, code: p.code, reason: p.reason }),
335
+ },
336
+ "human.input_requested": {
337
+ toFile: (p) => ({ call_id: p.callId, question: p.question }),
338
+ fromFile: (p) => ({ callId: p.call_id, question: p.question }),
339
+ },
340
+ "human.input_provided": {
341
+ toFile: (p) => ({ call_id: p.callId, answer: p.answer }),
342
+ fromFile: (p) => ({ callId: p.call_id, answer: p.answer }),
343
+ },
344
+ "usage.recorded": {
345
+ toFile: (p) => p.kind === "tool_execution"
346
+ ? { kind: p.kind, provider: p.provider, usage: { duration_ms: p.usage.durationMs } }
347
+ : { kind: p.kind, provider: p.provider, model: p.model, usage: usageToFile(p.usage) },
348
+ fromFile: (p) => p.kind === "tool_execution"
349
+ ? { kind: p.kind, provider: p.provider, usage: { durationMs: p.usage.duration_ms } }
350
+ : { kind: p.kind, provider: p.provider, model: p.model, usage: usageFromFile(p.usage) },
351
+ },
352
+ };
353
+ function payloadToFile(type, payload) {
354
+ const codec = codecs[type];
355
+ return codec ? codec.toFile(payload) : payload;
356
+ }
357
+ function payloadFromFile(type, payload) {
358
+ const codec = codecs[type];
359
+ return codec ? codec.fromFile(payload) : payload;
360
+ }
@@ -0,0 +1,38 @@
1
+ import type { AnyEvent } from "./events.ts";
2
+ /** Why a client gives up on a work, as recorded in `work.failed`. */
3
+ export type FailureReason = "limit_reached" | "model_refusal" | "model_error";
4
+ /**
5
+ * Counts the tool calls of a work the way the limits do: every call the runtime started, plus
6
+ * every rejection that never became a call. A rejection of a started call is not a second call.
7
+ */
8
+ export declare function countToolCalls(events: readonly AnyEvent[]): number;
9
+ export interface PendingQuestion {
10
+ callId: string;
11
+ question: string;
12
+ }
13
+ /**
14
+ * The questions of the work that have no answer yet, oldest first. Call ids of questions are
15
+ * minted by the runtime, so the whole log is searched: a client recording its own model turns
16
+ * must not hide a question.
17
+ */
18
+ export declare function pendingQuestions(events: readonly AnyEvent[]): PendingQuestion[];
19
+ export interface HistoryCall {
20
+ callId: string;
21
+ name: string;
22
+ /** The path the call named, when its input had one. */
23
+ path?: string;
24
+ /** Present once the call has a result; absent while it is still open. */
25
+ isError?: boolean;
26
+ rejected?: string;
27
+ }
28
+ export interface WorkHistory {
29
+ calls: HistoryCall[];
30
+ /** Calls that were started but have no result: the work stopped while they ran. */
31
+ unfinished: HistoryCall[];
32
+ pending: PendingQuestion[];
33
+ toolCalls: number;
34
+ /** Model calls recorded on the work, for a client that counts them against a limit. */
35
+ modelCalls: number;
36
+ }
37
+ /** What a client needs to pick a work up where it stopped. Built from the log alone. */
38
+ export declare function workHistory(events: readonly AnyEvent[]): WorkHistory;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Counts the tool calls of a work the way the limits do: every call the runtime started, plus
3
+ * every rejection that never became a call. A rejection of a started call is not a second call.
4
+ */
5
+ export function countToolCalls(events) {
6
+ let count = 0;
7
+ let started = new Set();
8
+ for (const event of events) {
9
+ if (event.type === "model.completed")
10
+ started = new Set();
11
+ else if (event.type === "tool.called") {
12
+ started.add(event.payload.callId);
13
+ count += 1;
14
+ }
15
+ else if (event.type === "tool.rejected") {
16
+ if (!started.has(event.payload.callId))
17
+ count += 1;
18
+ }
19
+ }
20
+ return count;
21
+ }
22
+ /**
23
+ * The questions of the work that have no answer yet, oldest first. Call ids of questions are
24
+ * minted by the runtime, so the whole log is searched: a client recording its own model turns
25
+ * must not hide a question.
26
+ */
27
+ export function pendingQuestions(events) {
28
+ const answered = new Set(events
29
+ .filter((e) => e.type === "human.input_provided")
30
+ .map((e) => e.payload.callId));
31
+ return events
32
+ .filter((e) => e.type === "human.input_requested")
33
+ .filter((e) => !answered.has(e.payload.callId))
34
+ .map((e) => ({ callId: e.payload.callId, question: e.payload.question }));
35
+ }
36
+ /** What a client needs to pick a work up where it stopped. Built from the log alone. */
37
+ export function workHistory(events) {
38
+ const calls = [];
39
+ const byId = new Map();
40
+ for (const event of events) {
41
+ if (event.type === "tool.called") {
42
+ const { callId, name, input } = event.payload;
43
+ const path = input?.path;
44
+ const call = { callId, name, ...(typeof path === "string" && { path }) };
45
+ calls.push(call);
46
+ byId.set(callId, call);
47
+ }
48
+ else if (event.type === "tool.completed") {
49
+ const { callId, isError } = event.payload;
50
+ const call = byId.get(callId);
51
+ if (call)
52
+ call.isError = isError;
53
+ }
54
+ else if (event.type === "tool.rejected") {
55
+ const { callId, name, code } = event.payload;
56
+ const call = byId.get(callId);
57
+ if (call)
58
+ call.rejected = code;
59
+ else
60
+ calls.push({ callId, name, rejected: code });
61
+ }
62
+ }
63
+ return {
64
+ calls,
65
+ unfinished: calls.filter((c) => c.isError === undefined && c.rejected === undefined),
66
+ pending: pendingQuestions(events),
67
+ toolCalls: countToolCalls(events),
68
+ modelCalls: events.filter((e) => e.type === "model.requested").length,
69
+ };
70
+ }
@@ -0,0 +1,13 @@
1
+ export declare const LOCK_FILE_NAME = "lock";
2
+ export interface Lock {
3
+ release(): Promise<void>;
4
+ }
5
+ /**
6
+ * Takes the single-writer lock of a work directory. A lock left behind by a
7
+ * process that no longer exists, or one that cannot be read, is taken over.
8
+ * Release only removes the file while it still records this holder.
9
+ *
10
+ * Known limit: liveness is judged by pid. A pid reused by an unrelated process
11
+ * keeps the lock held until that process ends or the file is removed by hand.
12
+ */
13
+ export declare function acquireLock(dir: string): Promise<Lock>;
@@ -0,0 +1,80 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { OpenshainError } from "../errors.js";
4
+ export const LOCK_FILE_NAME = "lock";
5
+ /**
6
+ * Takes the single-writer lock of a work directory. A lock left behind by a
7
+ * process that no longer exists, or one that cannot be read, is taken over.
8
+ * Release only removes the file while it still records this holder.
9
+ *
10
+ * Known limit: liveness is judged by pid. A pid reused by an unrelated process
11
+ * keeps the lock held until that process ends or the file is removed by hand.
12
+ */
13
+ export async function acquireLock(dir) {
14
+ try {
15
+ await mkdir(dir, { recursive: true });
16
+ }
17
+ catch (cause) {
18
+ throw new OpenshainError("invalid_path", `cannot create the work directory ${dir}`, { cause });
19
+ }
20
+ const path = join(dir, LOCK_FILE_NAME);
21
+ const holder = { pid: process.pid, startedAt: new Date().toISOString() };
22
+ const content = JSON.stringify({ pid: holder.pid, started_at: holder.startedAt });
23
+ for (let attempt = 0; attempt < 3; attempt++) {
24
+ try {
25
+ await writeFile(path, content, { flag: "wx" });
26
+ return lockHandle(path, holder);
27
+ }
28
+ catch (err) {
29
+ if (err.code !== "EEXIST") {
30
+ throw new OpenshainError("invalid_path", `cannot write the lock file ${path}`, {
31
+ cause: err,
32
+ });
33
+ }
34
+ }
35
+ const current = await readHolder(path);
36
+ if (current && isAlive(current.pid)) {
37
+ throw new OpenshainError("lock_held", `${dir} is locked by process ${current.pid} since ${current.startedAt}`);
38
+ }
39
+ await rm(path, { force: true });
40
+ }
41
+ throw new OpenshainError("lock_held", `${dir}: could not take the lock`);
42
+ }
43
+ async function readHolder(path) {
44
+ try {
45
+ const parsed = JSON.parse(await readFile(path, "utf8"));
46
+ if (typeof parsed.pid !== "number")
47
+ return undefined;
48
+ return { pid: parsed.pid, startedAt: String(parsed.started_at ?? "unknown") };
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ /** Only real process ids count. 0 and negatives address process groups and would always "exist". */
55
+ function isAlive(pid) {
56
+ if (!Number.isInteger(pid) || pid <= 1)
57
+ return false;
58
+ try {
59
+ process.kill(pid, 0);
60
+ return true;
61
+ }
62
+ catch (err) {
63
+ return err.code === "EPERM";
64
+ }
65
+ }
66
+ function lockHandle(path, holder) {
67
+ let released = false;
68
+ return {
69
+ async release() {
70
+ if (released)
71
+ return;
72
+ released = true;
73
+ const current = await readHolder(path);
74
+ if (current && (current.pid !== holder.pid || current.startedAt !== holder.startedAt)) {
75
+ return; // someone else holds it now; not ours to remove
76
+ }
77
+ await rm(path, { force: true });
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,31 @@
1
+ import type { Config } from "../config/schema.ts";
2
+ import type { ModelMessage } from "../model/types.ts";
3
+ import type { ToolDefinition } from "../tool/types.ts";
4
+ import { type AnyEvent } from "./events.ts";
5
+ export interface ProjectionInput {
6
+ events: readonly AnyEvent[];
7
+ config: Pick<Config, "company" | "principal" | "profession">;
8
+ /** Tool definitions the model may call. Already filtered by the allow lists. */
9
+ tools: ToolDefinition[];
10
+ /** Opaque parts are returned only to the provider that produced them. */
11
+ providerId: string;
12
+ budget: {
13
+ modelCallsLeft: number;
14
+ toolCallsLeft: number;
15
+ };
16
+ }
17
+ export interface Projection {
18
+ system: string;
19
+ messages: ModelMessage[];
20
+ tools: ToolDefinition[];
21
+ budget: {
22
+ modelCallsLeft: number;
23
+ toolCallsLeft: number;
24
+ };
25
+ }
26
+ /**
27
+ * What the model sees. Built from the event log alone, in order, and therefore
28
+ * the same bytes every time for the same events. Nothing is rewritten: the
29
+ * budget line is a user message of its own at the end.
30
+ */
31
+ export declare function buildProjection(input: ProjectionInput): Projection;