hoi4-agent-tools 3.0.9 → 3.1.0
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/CHANGELOG.md +23 -1
- package/README.md +4 -0
- package/dist/bin/stdio.js +23 -12
- package/dist/hoi4_agent_tools/core/analysis-checkpoints.js +82 -0
- package/dist/hoi4_agent_tools/core/artifact-resource.js +173 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +84 -2
- package/dist/hoi4_agent_tools/core/dependency-pages.js +216 -0
- package/dist/hoi4_agent_tools/core/dependency-walk.js +266 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +188 -0
- package/dist/hoi4_agent_tools/core/engine.js +48 -5
- package/dist/hoi4_agent_tools/core/helper-expansion.js +186 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +137 -0
- package/dist/hoi4_agent_tools/core/index.js +47 -4
- package/dist/hoi4_agent_tools/core/job-controls.js +109 -0
- package/dist/hoi4_agent_tools/core/job-executor.js +190 -0
- package/dist/hoi4_agent_tools/core/job-service.js +112 -0
- package/dist/hoi4_agent_tools/core/job-store.js +641 -0
- package/dist/hoi4_agent_tools/core/job-transactions.js +126 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +200 -0
- package/dist/hoi4_agent_tools/core/job-worker.js +62 -0
- package/dist/hoi4_agent_tools/core/operation-context.js +31 -0
- package/dist/hoi4_agent_tools/core/operation-result.js +412 -0
- package/dist/hoi4_agent_tools/core/operation-tasks.js +382 -0
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +383 -0
- package/dist/hoi4_agent_tools/core/result.js +1 -0
- package/dist/hoi4_agent_tools/core/scanner.js +15 -75
- package/dist/hoi4_agent_tools/core/semantic-dependencies.js +233 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +43 -11
- package/dist/hoi4_agent_tools/core/source/cache.js +107 -0
- package/dist/hoi4_agent_tools/core/source/parser.js +11 -1
- package/dist/hoi4_agent_tools/core/transaction-execution.js +134 -0
- package/dist/hoi4_agent_tools/core/transactions.js +80 -27
- package/dist/hoi4_agent_tools/event/graph.js +214 -290
- package/dist/hoi4_agent_tools/event/helper-expansion.js +33 -0
- package/dist/hoi4_agent_tools/event/job-operations.js +51 -0
- package/dist/hoi4_agent_tools/event/operations.js +179 -0
- package/dist/hoi4_agent_tools/event/service.js +67 -15
- package/dist/hoi4_agent_tools/event/source-analysis.js +5 -4
- package/dist/hoi4_agent_tools/focus/job-operations.js +77 -0
- package/dist/hoi4_agent_tools/focus/tool-operations.js +669 -0
- package/dist/hoi4_agent_tools/focus/tool-support.js +123 -0
- package/dist/hoi4_agent_tools/gui/job-operations.js +81 -0
- package/dist/hoi4_agent_tools/gui/tool-operations.js +270 -0
- package/dist/hoi4_agent_tools/map/job-operations.js +76 -0
- package/dist/hoi4_agent_tools/map/tool-operations.js +493 -0
- package/dist/hoi4_agent_tools/mcp/prompts/probability.js +25 -0
- package/dist/hoi4_agent_tools/mcp/prompts/register-modern.js +29 -0
- package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +34 -0
- package/dist/hoi4_agent_tools/mcp/resources/register.js +4 -150
- package/dist/hoi4_agent_tools/mcp/server/base-tools.js +3 -219
- package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +188 -0
- package/dist/hoi4_agent_tools/mcp/server/create.js +16 -6
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +2 -0
- package/dist/hoi4_agent_tools/mcp/server/job-task-store.js +67 -0
- package/dist/hoi4_agent_tools/mcp/server/legacy-task-tools.js +20 -0
- package/dist/hoi4_agent_tools/mcp/server/modern-tasks.js +168 -0
- package/dist/hoi4_agent_tools/mcp/server/progress.js +17 -15
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +43 -0
- package/dist/hoi4_agent_tools/mcp/server/result.js +2 -412
- package/dist/hoi4_agent_tools/mcp/server/task-request-context.js +9 -0
- package/dist/hoi4_agent_tools/mcp/server/task-tool-catalog.js +14 -0
- package/dist/hoi4_agent_tools/mcp/server/task-tool-definition.js +1 -0
- package/dist/hoi4_agent_tools/mcp/server/transaction-execution.js +1 -123
- package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +38 -21
- package/dist/hoi4_agent_tools/mcp/tools/event.js +24 -315
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +34 -1039
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +19 -504
- package/dist/hoi4_agent_tools/mcp/tools/job.js +46 -0
- package/dist/hoi4_agent_tools/mcp/tools/map.js +21 -600
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +39 -463
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +40 -304
- package/dist/hoi4_agent_tools/mcp/transports/http.js +58 -1
- package/dist/hoi4_agent_tools/mcp/transports/modern-task-routing.js +109 -0
- package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +121 -0
- package/dist/hoi4_agent_tools/probability/job-operations.js +58 -0
- package/dist/hoi4_agent_tools/probability/operations.js +183 -0
- package/dist/hoi4_agent_tools/probability/service.js +24 -3
- package/dist/hoi4_agent_tools/schemas/common.js +2 -0
- package/dist/hoi4_agent_tools/schemas/event.js +72 -1
- package/dist/hoi4_agent_tools/schemas/focus-requests.js +189 -0
- package/dist/hoi4_agent_tools/schemas/gui-requests.js +193 -0
- package/dist/hoi4_agent_tools/schemas/helper-expansion.js +58 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +85 -0
- package/dist/hoi4_agent_tools/schemas/probability-requests.js +168 -0
- package/dist/hoi4_agent_tools/schemas/technology.js +90 -1
- package/dist/hoi4_agent_tools/schemas/transaction.js +1 -0
- package/dist/hoi4_agent_tools/technology/graph.js +83 -78
- package/dist/hoi4_agent_tools/technology/helper-expansion.js +33 -0
- package/dist/hoi4_agent_tools/technology/job-operations.js +60 -0
- package/dist/hoi4_agent_tools/technology/operations.js +125 -0
- package/dist/hoi4_agent_tools/technology/service.js +89 -15
- package/dist/hoi4_agent_tools/technology/source-analysis.js +2 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +2 -0
- package/docs/development.md +2 -0
- package/docs/events.md +7 -2
- package/docs/helper-expansion.md +78 -0
- package/docs/http.md +2 -0
- package/docs/jobs.md +85 -0
- package/docs/technology.md +6 -2
- package/package.json +11 -5
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +44 -0
- package/schemas/helper-expansion-summary.schema.json +70 -0
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 3.1.0 - 2026-09-20
|
|
6
|
+
|
|
7
|
+
- Reuse authenticated content-addressed parsed documents and file-local index segments across authorized local processes while revalidating current source bytes, load order, shadowing, workspace topology, and principal scope.
|
|
8
|
+
- Rebuild exact indexes from immutable segments, retain typed reverse source dependencies, and heal malformed or tampered persistent cache entries without trusting timestamps or file sizes.
|
|
9
|
+
- Run event, technology, probability, map, GUI, and focus analysis in fixed-entry isolated workers backed by durable authenticated jobs, revision-fenced ownership, bounded retention, and source-revision result checkpoints.
|
|
10
|
+
- Advertise optional negotiated MCP tasks for all long-running domain calls while preserving exact ordinary-call results, progress, cancellation, linked resources, tool errors, and both stdio and HTTP compatibility.
|
|
11
|
+
- Share task execution, authorization, and all 23 domain tool definitions with an MCP 2026-07-28 Tasks extension adapter, routed by production stdio and authenticated HTTP entry points.
|
|
12
|
+
- Share `hoi4.job_inspect` and `hoi4.job_cancel` between protocol adapters as responsive, principal-private compatibility controls that remain available while domain execution slots are occupied.
|
|
13
|
+
- Share the bounded probability-analysis prompt metadata and body between protocol adapters without starting jobs or source analysis.
|
|
14
|
+
- Advertise the same server instructions through legacy initialization and modern discovery.
|
|
15
|
+
- Deliver opt-in modern request progress through the SDK's related-notification API, preserving ordered heartbeats and cancellation over connection and auto-streaming production HTTP serving.
|
|
16
|
+
- Pin each bounded stdio connection to its opening protocol era and route authenticated modern HTTP claims through strict SDK validation without changing legacy stateful sessions or the existing request-security gates.
|
|
17
|
+
- Share the two opt-in ChaosX private-tool implementations between stdio protocol generations while keeping them absent from ordinary and HTTP catalogs.
|
|
18
|
+
- Pin patched Sharp, Hono, Vitest, and coverage packages while retaining the exact dependency graph in the lockfile.
|
|
19
|
+
- Require caller-stable request keys for native background focus, GUI, and map rewrites; deduplicate exact retries independently of polling hints and reject conflicting reuse.
|
|
20
|
+
- Derive new job IDs from private server state, preserve authenticated earlier retry receipts without replay, and authenticate modern task handles before execution admission or cancellation.
|
|
21
|
+
- Renew only task-visibility metadata for exact retries of expired rewrite receipts, preserving terminal outcomes and source edits through native and ordinary calls in both protocol generations.
|
|
22
|
+
- Share bounded artifact-resource reads between protocol generations, including exact byte ranges, continuation links, manifest projection, and workspace-grant checks.
|
|
23
|
+
- Bind rewrite transactions and domain completion recipes before source application, reconcile stopped workers without replanning, and retain post-write validation artifacts without changing immutable plan hashes.
|
|
24
|
+
- Retain revision-addressed event and technology graphs for comparisons after process restart, and preserve explicit helper, source, candidate, node, edge, and render coverage boundaries.
|
|
25
|
+
- Add opt-in bounded `helper_expansion` pages for event and technology inspection, preserving distinct source call paths and resumable principal- and revision-bound cursors without retaining completed traversal history.
|
|
26
|
+
- Mark depth- and projection-truncated materialized helper graphs incomplete, and retain explicit cycle, dynamic-reference, source-coverage, and page-retention boundaries.
|
|
27
|
+
- Harden Windows atomic job publication, bounded pre-dispatch worker-lease transfer retries, disconnect recovery, principal isolation, task expiry, and mutation-receipt retention under concurrent clients.
|
|
6
28
|
|
|
7
29
|
## 3.0.9 - 2026-09-08
|
|
8
30
|
|
package/README.md
CHANGED
|
@@ -71,6 +71,8 @@ On non-Windows systems, use `hoi4-agent-tools` as the command. Agentic HOI4 repo
|
|
|
71
71
|
| `hoi4.event_inspect` | Scan, trace, explain, lint, or assess event chains and their state flow. |
|
|
72
72
|
| `hoi4.event_render` | Render source-linked event routes, options, timing, state, scope, and unresolved edges. |
|
|
73
73
|
| `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
|
|
74
|
+
| `hoi4.job_inspect` | Inspect durable background work or retrieve its completed tool result. |
|
|
75
|
+
| `hoi4.job_cancel` | Durably request cancellation of authorized background work. |
|
|
74
76
|
| `hoi4.tech_inspect` | Scan, trace, explain, lint, and assess technology and doctrine systems. |
|
|
75
77
|
| `hoi4.tech_render` | Render source layouts with real item sizes and year guides, plus dependencies and assets. |
|
|
76
78
|
| `hoi4.tech_compare` | Compare technology graphs, placements, references, diagnostics, and source overlays. |
|
|
@@ -86,6 +88,8 @@ Set `HOI4_AGENT_TOOLS_CHAOSX=1` on the server process to expose the optional `ch
|
|
|
86
88
|
|
|
87
89
|
Large outputs are linked `hoi4-agent://` resources. For resources over 1 MiB, follow the `continuationUri` returned in `_meta` until it is `null`; clients may also request byte ranges with `?offset=<bytes>&length=<bytes>`.
|
|
88
90
|
|
|
91
|
+
Long-running domain calls advertise optional MCP task support. A task survives client disconnects and can be retrieved after reconnect; clients without task support keep the ordinary synchronous result contract. Native background rewrites require a stable caller-supplied `requestKey` so retries resolve to one durable mutation receipt. See [Persistent jobs and MCP tasks](docs/jobs.md).
|
|
92
|
+
|
|
89
93
|
## Coexistence with agent workflows
|
|
90
94
|
|
|
91
95
|
HOI4 Agent Tools provides HOI4 domain operations without replacing repository instructions such as `AGENTS.md`, skills, plans, or subagents. Its optional weighted-logic prompt helps an agent scope one probability analysis and then returns control to the normal workflow.
|
package/dist/bin/stdio.js
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { canonicalJson } from '../hoi4_agent_tools/core/canonical.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { FinalProtocolTransport } from '../hoi4_agent_tools/mcp/transports/protocol-gate.js';
|
|
3
|
+
import { StdioFrameLimitError, StdioInvalidMessageError, StdioInvalidUtf8Error, } from '../hoi4_agent_tools/mcp/transports/bounded-stdio.js';
|
|
4
|
+
import { serveNegotiatedStdio } from '../hoi4_agent_tools/mcp/transports/negotiated-stdio.js';
|
|
6
5
|
import { createEngine } from '../hoi4_agent_tools/runtime.js';
|
|
7
6
|
async function main() {
|
|
8
7
|
const engine = await createEngine();
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
registerChaosxTools(server, engine, context);
|
|
14
|
-
}
|
|
15
|
-
server.server.onerror = (error) => {
|
|
8
|
+
const chaosxTools = process.env.HOI4_AGENT_TOOLS_CHAOSX === '1'
|
|
9
|
+
? await import('../hoi4_agent_tools/mcp/tools/chaosx.js')
|
|
10
|
+
: undefined;
|
|
11
|
+
const onerror = (error) => {
|
|
16
12
|
const fatalInputError = error instanceof StdioFrameLimitError || error instanceof StdioInvalidUtf8Error;
|
|
17
13
|
const rejectedMessage = error instanceof StdioInvalidMessageError;
|
|
18
14
|
if (fatalInputError)
|
|
@@ -24,8 +20,23 @@ async function main() {
|
|
|
24
20
|
message: error.message,
|
|
25
21
|
})}\n`);
|
|
26
22
|
};
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
await serveNegotiatedStdio(engine, {
|
|
24
|
+
onerror,
|
|
25
|
+
onFailure: (error) => {
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
process.stderr.write(`${canonicalJson({
|
|
28
|
+
level: 'error',
|
|
29
|
+
event: 'transport_startup_failed',
|
|
30
|
+
message: error instanceof Error ? error.message : String(error),
|
|
31
|
+
})}\n`);
|
|
32
|
+
},
|
|
33
|
+
...(chaosxTools === undefined
|
|
34
|
+
? {}
|
|
35
|
+
: {
|
|
36
|
+
createModernPrivateTools: chaosxTools.createChaosxToolOperations,
|
|
37
|
+
registerLegacy: (server) => chaosxTools.registerChaosxTools(server, engine, {}),
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
29
40
|
}
|
|
30
41
|
main().catch((error) => {
|
|
31
42
|
process.stderr.write(`${canonicalJson({
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { z } from 'zod/v4';
|
|
3
|
+
import { canonicalJson, hashCanonical } from './canonical.js';
|
|
4
|
+
import { ServiceError } from './result.js';
|
|
5
|
+
import { PACKAGE_VERSION } from '../version.js';
|
|
6
|
+
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
7
|
+
const envelopeSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
version: z.literal(1),
|
|
10
|
+
key: digest,
|
|
11
|
+
sourceRevision: digest,
|
|
12
|
+
state: z.json(),
|
|
13
|
+
})
|
|
14
|
+
.strict();
|
|
15
|
+
const active = new AsyncLocalStorage();
|
|
16
|
+
/** Internal typed execution context; no transport or caller-supplied executable hooks. */
|
|
17
|
+
export function withAnalysisCheckpoints(checkpoints, run) {
|
|
18
|
+
return active.run(checkpoints, run);
|
|
19
|
+
}
|
|
20
|
+
export function currentAnalysisCheckpoints() {
|
|
21
|
+
return active.getStore();
|
|
22
|
+
}
|
|
23
|
+
/** The authenticated job record commits to the exact immutable checkpoint bytes. */
|
|
24
|
+
export function jobAnalysisCheckpoints(context) {
|
|
25
|
+
let checkpoint = context.checkpoint;
|
|
26
|
+
const workspace = context.engine.resolver.get(context.workspaceId, context.principal);
|
|
27
|
+
return {
|
|
28
|
+
async load(key, sourceRevision, schema) {
|
|
29
|
+
if (checkpoint?.cursor !== `analysis:${key}` || checkpoint.sourceRevision !== sourceRevision)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (checkpoint.resourceUri === undefined || checkpoint.stateHash === undefined)
|
|
32
|
+
throw new ServiceError('ANALYSIS_CHECKPOINT_INVALID', 'Analysis checkpoint has no authenticated state');
|
|
33
|
+
const resource = await context.engine.artifacts.readLogical(workspace, checkpoint.resourceUri, {
|
|
34
|
+
mimeType: 'application/json',
|
|
35
|
+
maxBytes: 268_435_456,
|
|
36
|
+
maxChunks: 256,
|
|
37
|
+
}, context.signal);
|
|
38
|
+
const envelope = envelopeSchema.parse(JSON.parse(resource.bytes.toString('utf8')));
|
|
39
|
+
if (envelope.key !== key ||
|
|
40
|
+
envelope.sourceRevision !== sourceRevision ||
|
|
41
|
+
hashCanonical(envelope) !== checkpoint.stateHash)
|
|
42
|
+
throw new ServiceError('ANALYSIS_CHECKPOINT_INVALID', 'Analysis checkpoint identity or content does not match its job');
|
|
43
|
+
return schema.parse(envelope.state);
|
|
44
|
+
},
|
|
45
|
+
async save(key, sourceRevision, state, completed, total) {
|
|
46
|
+
context.signal.throwIfAborted();
|
|
47
|
+
const envelope = envelopeSchema.parse({ version: 1, key, sourceRevision, state });
|
|
48
|
+
await context.engine.artifacts.withAtomicChunkedWrites(workspace, [
|
|
49
|
+
{
|
|
50
|
+
name: 'analysis-checkpoint.json',
|
|
51
|
+
mimeType: 'application/json',
|
|
52
|
+
content: canonicalJson(envelope),
|
|
53
|
+
provenance: {
|
|
54
|
+
kind: 'analysis-checkpoint',
|
|
55
|
+
toolVersion: PACKAGE_VERSION,
|
|
56
|
+
schemaVersion: '1',
|
|
57
|
+
sourceHashes: {},
|
|
58
|
+
metadata: { key, sourceRevision },
|
|
59
|
+
},
|
|
60
|
+
description: 'Revision-bound internal dependency-expansion checkpoint',
|
|
61
|
+
},
|
|
62
|
+
], async ([resource], physical) => {
|
|
63
|
+
const next = {
|
|
64
|
+
sourceRevision,
|
|
65
|
+
resourceUri: resource.uri,
|
|
66
|
+
cursor: `analysis:${key}`,
|
|
67
|
+
stateHash: hashCanonical(envelope),
|
|
68
|
+
resources: physical.map(({ uri }) => uri),
|
|
69
|
+
};
|
|
70
|
+
// Publish the authenticated pin before releasing artifact admission. A competing
|
|
71
|
+
// writer therefore sees either the old complete checkpoint or the new one.
|
|
72
|
+
await context.saveCheckpoint(next);
|
|
73
|
+
checkpoint = next;
|
|
74
|
+
}, context.signal);
|
|
75
|
+
await context.progress({
|
|
76
|
+
completed,
|
|
77
|
+
total,
|
|
78
|
+
message: 'Expanding source-bound helper dependencies',
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { canonicalJson } from './canonical.js';
|
|
2
|
+
import { ServiceError } from './result.js';
|
|
3
|
+
const artifactResourceLimit = 1_048_576;
|
|
4
|
+
const artifactChunkMetadataKey = 'io.github.klimpaskov/hoi4-agent-tools.artifact-byte-range';
|
|
5
|
+
const fatalUtf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
6
|
+
export const artifactResourceDefinition = {
|
|
7
|
+
name: 'artifact',
|
|
8
|
+
uriTemplate: 'hoi4-agent://workspace/{workspaceId}/artifact/{sha256}/{provenanceHash}/{name}',
|
|
9
|
+
title: 'HOI4 review artifact',
|
|
10
|
+
description: 'Complete plans, diagnostics, layouts, previews, event graphs, routes, state and scope reports, comparisons, and diffs. Reads accept zero-based byte offset and positive byte length query selectors; each response reports its exact byte range and continuation URI in _meta.',
|
|
11
|
+
_meta: {
|
|
12
|
+
[artifactChunkMetadataKey]: {
|
|
13
|
+
version: 1,
|
|
14
|
+
unit: 'byte',
|
|
15
|
+
maxChunkSize: artifactResourceLimit,
|
|
16
|
+
selectors: {
|
|
17
|
+
offset: { type: 'integer', minimum: 0, default: 0 },
|
|
18
|
+
length: {
|
|
19
|
+
type: 'integer',
|
|
20
|
+
minimum: 1,
|
|
21
|
+
maximum: artifactResourceLimit,
|
|
22
|
+
default: artifactResourceLimit,
|
|
23
|
+
},
|
|
24
|
+
metadata: { type: 'string', enum: ['manifest'], optional: true },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
function assertResourceSelectors(uri, allowed) {
|
|
30
|
+
if (uri.hash !== '' || uri.username !== '' || uri.password !== '' || uri.port !== '') {
|
|
31
|
+
throw new ServiceError('RESOURCE_URI_INVALID', 'Resource URI is not canonical');
|
|
32
|
+
}
|
|
33
|
+
const allowedNames = new Set(allowed);
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
for (const name of uri.searchParams.keys()) {
|
|
36
|
+
if (!allowedNames.has(name)) {
|
|
37
|
+
throw new ServiceError('RESOURCE_SELECTOR_INVALID', 'Resource selector is not supported');
|
|
38
|
+
}
|
|
39
|
+
if (seen.has(name)) {
|
|
40
|
+
throw new ServiceError('RESOURCE_SELECTOR_INVALID', 'Resource selectors may not be repeated');
|
|
41
|
+
}
|
|
42
|
+
seen.add(name);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function contentArtifactUri(uri) {
|
|
46
|
+
const contentUri = new URL(uri);
|
|
47
|
+
contentUri.search = '';
|
|
48
|
+
contentUri.hash = '';
|
|
49
|
+
return contentUri.href;
|
|
50
|
+
}
|
|
51
|
+
function exactUtf8Text(bytes) {
|
|
52
|
+
try {
|
|
53
|
+
const text = fatalUtf8Decoder.decode(bytes);
|
|
54
|
+
return Buffer.from(text, 'utf8').equals(bytes) ? text : undefined;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function artifactRangeParameter(uri, name, fallback, minimum, maximum = Number.MAX_SAFE_INTEGER) {
|
|
61
|
+
const raw = uri.searchParams.get(name);
|
|
62
|
+
if (raw === null)
|
|
63
|
+
return fallback;
|
|
64
|
+
if (!/^\d+$/u.test(raw)) {
|
|
65
|
+
throw new ServiceError('ARTIFACT_RANGE_INVALID', 'Artifact byte range is invalid');
|
|
66
|
+
}
|
|
67
|
+
const value = Number(raw);
|
|
68
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
69
|
+
throw new ServiceError('ARTIFACT_RANGE_INVALID', 'Artifact byte range is invalid');
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function artifactChunkMetadata(uri, metadata, totalSize, offset, returnedLength, requestedLength) {
|
|
74
|
+
const endExclusive = offset + returnedLength;
|
|
75
|
+
let continuationUri = null;
|
|
76
|
+
if (endExclusive < totalSize) {
|
|
77
|
+
const continuation = new URL(contentArtifactUri(uri));
|
|
78
|
+
continuation.searchParams.set('offset', String(endExclusive));
|
|
79
|
+
continuation.searchParams.set('length', String(requestedLength));
|
|
80
|
+
if (metadata !== null)
|
|
81
|
+
continuation.searchParams.set('metadata', metadata);
|
|
82
|
+
continuationUri = continuation.href;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
[artifactChunkMetadataKey]: {
|
|
86
|
+
version: 1,
|
|
87
|
+
unit: 'byte',
|
|
88
|
+
totalSize,
|
|
89
|
+
returnedRange: { offset, length: returnedLength, endExclusive },
|
|
90
|
+
complete: offset === 0 && returnedLength === totalSize,
|
|
91
|
+
continuationUri,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Shared bounded resource reader. URI ownership never supplies a principal or root grant. */
|
|
96
|
+
export async function readArtifactResource(engine, uriInput, context, signal) {
|
|
97
|
+
signal.throwIfAborted();
|
|
98
|
+
let uri;
|
|
99
|
+
let workspaceId;
|
|
100
|
+
try {
|
|
101
|
+
if (uriInput.length > 8192)
|
|
102
|
+
throw new Error('URI exceeds the resource limit');
|
|
103
|
+
uri = new URL(uriInput);
|
|
104
|
+
const segments = uri.pathname.split('/');
|
|
105
|
+
if (uri.protocol !== 'hoi4-agent:' ||
|
|
106
|
+
uri.hostname !== 'workspace' ||
|
|
107
|
+
segments.length !== 6 ||
|
|
108
|
+
segments[0] !== '' ||
|
|
109
|
+
segments[2] !== 'artifact' ||
|
|
110
|
+
segments.some((segment, index) => index !== 0 && segment === ''))
|
|
111
|
+
throw new Error('Invalid resource path');
|
|
112
|
+
workspaceId = decodeURIComponent(segments[1]);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw new ServiceError('RESOURCE_URI_INVALID', 'Resource URI is invalid');
|
|
116
|
+
}
|
|
117
|
+
signal.throwIfAborted();
|
|
118
|
+
assertResourceSelectors(uri, ['offset', 'length', 'metadata']);
|
|
119
|
+
const workspace = engine.resolver.get(workspaceId, context.principal);
|
|
120
|
+
const offset = artifactRangeParameter(uri, 'offset', 0, 0);
|
|
121
|
+
const length = artifactRangeParameter(uri, 'length', artifactResourceLimit, 1, artifactResourceLimit);
|
|
122
|
+
const metadata = uri.searchParams.get('metadata');
|
|
123
|
+
if (metadata !== null && metadata !== 'manifest') {
|
|
124
|
+
throw new ServiceError('ARTIFACT_METADATA_INVALID', 'Artifact metadata selector is invalid');
|
|
125
|
+
}
|
|
126
|
+
if (metadata === 'manifest') {
|
|
127
|
+
const described = await engine.artifacts.describe(workspace, contentArtifactUri(uri), signal);
|
|
128
|
+
const { path: artifactPath, ...publicManifest } = described;
|
|
129
|
+
void artifactPath;
|
|
130
|
+
const manifestBytes = Buffer.from(`${canonicalJson(publicManifest)}\n`, 'utf8');
|
|
131
|
+
const start = Math.min(offset, manifestBytes.length);
|
|
132
|
+
const selected = manifestBytes.subarray(start, Math.min(manifestBytes.length, start + length));
|
|
133
|
+
const complete = start === 0 && selected.length === manifestBytes.length;
|
|
134
|
+
const _meta = artifactChunkMetadata(uri, metadata, manifestBytes.length, start, selected.length, length);
|
|
135
|
+
return {
|
|
136
|
+
contents: [
|
|
137
|
+
complete
|
|
138
|
+
? {
|
|
139
|
+
uri: uri.href,
|
|
140
|
+
mimeType: 'application/json',
|
|
141
|
+
text: selected.toString('utf8'),
|
|
142
|
+
_meta,
|
|
143
|
+
}
|
|
144
|
+
: {
|
|
145
|
+
uri: uri.href,
|
|
146
|
+
mimeType: 'application/json',
|
|
147
|
+
blob: selected.toString('base64'),
|
|
148
|
+
_meta,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const artifact = await engine.artifacts.read(workspace, contentArtifactUri(uri), { offset, length }, signal);
|
|
154
|
+
const textual = artifact.mimeType.startsWith('text/') ||
|
|
155
|
+
artifact.mimeType === 'application/json' ||
|
|
156
|
+
artifact.mimeType === 'image/svg+xml';
|
|
157
|
+
const start = Math.min(offset, artifact.totalSize);
|
|
158
|
+
const complete = start === 0 && artifact.bytes.length === artifact.totalSize;
|
|
159
|
+
const text = textual && complete ? exactUtf8Text(artifact.bytes) : undefined;
|
|
160
|
+
const _meta = artifactChunkMetadata(uri, metadata, artifact.totalSize, start, artifact.bytes.length, length);
|
|
161
|
+
return {
|
|
162
|
+
contents: [
|
|
163
|
+
text !== undefined
|
|
164
|
+
? { uri: uri.href, mimeType: artifact.mimeType, text, _meta }
|
|
165
|
+
: {
|
|
166
|
+
uri: uri.href,
|
|
167
|
+
mimeType: artifact.mimeType,
|
|
168
|
+
blob: artifact.bytes.toString('base64'),
|
|
169
|
+
_meta,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -707,17 +707,27 @@ export class ArtifactStore {
|
|
|
707
707
|
maxBytes;
|
|
708
708
|
maxEntries;
|
|
709
709
|
maxSingleBytes;
|
|
710
|
+
retention;
|
|
710
711
|
#verifiedContent = new Map();
|
|
711
|
-
constructor(maxBytes = 536_870_912, maxEntries = 5_000, maxSingleBytes = 134_217_728) {
|
|
712
|
+
constructor(maxBytes = 536_870_912, maxEntries = 5_000, maxSingleBytes = 134_217_728, retention) {
|
|
712
713
|
this.maxBytes = maxBytes;
|
|
713
714
|
this.maxEntries = maxEntries;
|
|
714
715
|
this.maxSingleBytes = maxSingleBytes;
|
|
716
|
+
this.retention = retention;
|
|
715
717
|
}
|
|
716
718
|
async pruneForAdmission(workspace, usage, additionalBytes, additionalEntries, protectedManifests, protectedTargets, signal) {
|
|
717
719
|
if (usage.entries + additionalEntries <= this.maxEntries &&
|
|
718
720
|
usage.bytes <= this.maxBytes - additionalBytes) {
|
|
719
721
|
return usage;
|
|
720
722
|
}
|
|
723
|
+
// Read durable pins while holding the same cross-process publication lock used by
|
|
724
|
+
// checkpoint commits. A committed frontier cannot disappear between save and resume.
|
|
725
|
+
for (const uri of (await this.retention?.(workspace, signal)) ?? []) {
|
|
726
|
+
signal?.throwIfAborted();
|
|
727
|
+
const { sha256, provenanceHash, name } = this.parseUri(workspace, uri);
|
|
728
|
+
protectedTargets.add(await containedGeneratedPath(workspace.artifactRoot, sha256.slice(0, 2), sha256, name));
|
|
729
|
+
protectedManifests.add(await containedGeneratedPath(workspace.artifactRoot, sha256.slice(0, 2), sha256, `${name}.${provenanceHash}.manifest.json`));
|
|
730
|
+
}
|
|
721
731
|
// Reclaim to a low-water mark so a busy agent workflow does not rescan and evict one artifact
|
|
722
732
|
// for every subsequent tool call. The incoming batch itself remains protected and atomic.
|
|
723
733
|
const targetBytes = Math.max(0, Math.min(this.maxBytes - additionalBytes, Math.floor(this.maxBytes * 0.75)));
|
|
@@ -1222,7 +1232,7 @@ export class ArtifactStore {
|
|
|
1222
1232
|
if (physicalWrites.length !== admission.projectedEntries) {
|
|
1223
1233
|
throw new ServiceError('ARTIFACT_STORAGE_LIMIT', 'Artifact batch entry projection did not match prepared output');
|
|
1224
1234
|
}
|
|
1225
|
-
return this.withAtomicWrites(workspace, physicalWrites, (stored) => commit(logicalIndexes.map((index) => stored[index])), signal);
|
|
1235
|
+
return this.withAtomicWrites(workspace, physicalWrites, (stored) => commit(logicalIndexes.map((index) => stored[index]), stored), signal);
|
|
1226
1236
|
}
|
|
1227
1237
|
plannedArtifactLink(workspace, write, bytes, sha256) {
|
|
1228
1238
|
const manifestWithoutHash = {
|
|
@@ -1458,6 +1468,78 @@ export class ArtifactStore {
|
|
|
1458
1468
|
await refreshArtifactManifest(manifestPath);
|
|
1459
1469
|
return { bytes, mimeType: manifest.mimeType, name, totalSize };
|
|
1460
1470
|
}
|
|
1471
|
+
/** Read either one stored object or an authenticated chunk index as exact logical bytes. */
|
|
1472
|
+
async readLogical(workspace, uri, options, signal) {
|
|
1473
|
+
const initial = await this.read(workspace, uri, undefined, signal);
|
|
1474
|
+
if (initial.mimeType !== options.mimeType)
|
|
1475
|
+
throw new ServiceError('ARTIFACT_LOGICAL_TYPE_MISMATCH', `Logical artifact must use ${options.mimeType}`);
|
|
1476
|
+
let parsed;
|
|
1477
|
+
try {
|
|
1478
|
+
parsed = JSON.parse(initial.bytes.toString('utf8'));
|
|
1479
|
+
}
|
|
1480
|
+
catch {
|
|
1481
|
+
if (initial.totalSize > options.maxBytes)
|
|
1482
|
+
throw new ServiceError('ARTIFACT_LOGICAL_LIMIT', 'Logical artifact exceeds its fixed byte ceiling');
|
|
1483
|
+
return initial;
|
|
1484
|
+
}
|
|
1485
|
+
if (parsed === null ||
|
|
1486
|
+
typeof parsed !== 'object' ||
|
|
1487
|
+
Array.isArray(parsed) ||
|
|
1488
|
+
parsed.type !== 'hoi4-agent.chunked-artifact') {
|
|
1489
|
+
if (initial.totalSize > options.maxBytes)
|
|
1490
|
+
throw new ServiceError('ARTIFACT_LOGICAL_LIMIT', 'Logical artifact exceeds its fixed byte ceiling');
|
|
1491
|
+
return initial;
|
|
1492
|
+
}
|
|
1493
|
+
const index = parsed;
|
|
1494
|
+
if (index.schemaVersion !== 1 ||
|
|
1495
|
+
index.original === undefined ||
|
|
1496
|
+
!Array.isArray(index.chunks) ||
|
|
1497
|
+
index.chunks.length < 1 ||
|
|
1498
|
+
index.chunks.length > options.maxChunks ||
|
|
1499
|
+
index.original.mimeType !== options.mimeType ||
|
|
1500
|
+
!Number.isSafeInteger(index.original.size) ||
|
|
1501
|
+
index.original.size < 0 ||
|
|
1502
|
+
index.original.size > options.maxBytes ||
|
|
1503
|
+
!/^[a-f0-9]{64}$/u.test(index.original.sha256))
|
|
1504
|
+
throw new ServiceError('ARTIFACT_LOGICAL_INVALID', 'Chunked logical artifact index is malformed or exceeds its fixed limits');
|
|
1505
|
+
const buffers = [];
|
|
1506
|
+
const chunkUris = new Set();
|
|
1507
|
+
let offset = 0;
|
|
1508
|
+
for (const [chunkIndex, candidate] of index.chunks.entries()) {
|
|
1509
|
+
signal?.throwIfAborted();
|
|
1510
|
+
if (candidate.index !== chunkIndex ||
|
|
1511
|
+
candidate.offset !== offset ||
|
|
1512
|
+
!Number.isSafeInteger(candidate.length) ||
|
|
1513
|
+
candidate.length <= 0 ||
|
|
1514
|
+
candidate.size !== candidate.length ||
|
|
1515
|
+
candidate.mimeType !== 'application/octet-stream' ||
|
|
1516
|
+
typeof candidate.uri !== 'string' ||
|
|
1517
|
+
chunkUris.has(candidate.uri) ||
|
|
1518
|
+
!/^[a-f0-9]{64}$/u.test(candidate.sha256) ||
|
|
1519
|
+
offset + candidate.length > index.original.size)
|
|
1520
|
+
throw new ServiceError('ARTIFACT_LOGICAL_INVALID', 'Chunked logical artifact entry is malformed');
|
|
1521
|
+
chunkUris.add(candidate.uri);
|
|
1522
|
+
const chunk = await this.read(workspace, candidate.uri, undefined, signal);
|
|
1523
|
+
if (chunk.mimeType !== 'application/octet-stream' ||
|
|
1524
|
+
chunk.totalSize !== candidate.length ||
|
|
1525
|
+
chunk.bytes.length !== candidate.length ||
|
|
1526
|
+
sha256Bytes(chunk.bytes) !== candidate.sha256)
|
|
1527
|
+
throw new ServiceError('ARTIFACT_LOGICAL_INVALID', 'Chunked logical artifact content does not match its authenticated index');
|
|
1528
|
+
buffers.push(chunk.bytes);
|
|
1529
|
+
offset += chunk.bytes.length;
|
|
1530
|
+
}
|
|
1531
|
+
const bytes = Buffer.concat(buffers, index.original.size);
|
|
1532
|
+
if (offset !== index.original.size ||
|
|
1533
|
+
bytes.length !== index.original.size ||
|
|
1534
|
+
sha256Bytes(bytes) !== index.original.sha256)
|
|
1535
|
+
throw new ServiceError('ARTIFACT_LOGICAL_INVALID', 'Chunked logical artifact does not reconstruct its declared content');
|
|
1536
|
+
return {
|
|
1537
|
+
bytes,
|
|
1538
|
+
mimeType: index.original.mimeType,
|
|
1539
|
+
name: index.original.name,
|
|
1540
|
+
totalSize: index.original.size,
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1461
1543
|
uri(workspaceId, sha256, provenanceHash, name) {
|
|
1462
1544
|
return `hoi4-agent://workspace/${encodeURIComponent(workspaceId)}/artifact/${sha256}/${provenanceHash}/${encodeURIComponent(name)}`;
|
|
1463
1545
|
}
|