xcoder-jarvis-agent 0.0.0-stage → 1.3.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -2
  3. package/dist/index.d.ts +27 -0
  4. package/dist/index.js +28 -0
  5. package/dist/jarvis/agent.d.ts +57 -0
  6. package/dist/jarvis/agent.js +306 -0
  7. package/dist/jarvis/argv.d.ts +30 -0
  8. package/dist/jarvis/argv.js +138 -0
  9. package/dist/jarvis/budget.d.ts +49 -0
  10. package/dist/jarvis/budget.js +83 -0
  11. package/dist/jarvis/chat/confirm.d.ts +23 -0
  12. package/dist/jarvis/chat/confirm.js +37 -0
  13. package/dist/jarvis/chat/lines.d.ts +41 -0
  14. package/dist/jarvis/chat/lines.js +93 -0
  15. package/dist/jarvis/chat/prompt.d.ts +10 -0
  16. package/dist/jarvis/chat/prompt.js +18 -0
  17. package/dist/jarvis/chat/session.d.ts +38 -0
  18. package/dist/jarvis/chat/session.js +122 -0
  19. package/dist/jarvis/chat/terminator.d.ts +14 -0
  20. package/dist/jarvis/chat/terminator.js +19 -0
  21. package/dist/jarvis/decision.d.ts +64 -0
  22. package/dist/jarvis/decision.js +116 -0
  23. package/dist/jarvis/memory/store.d.ts +88 -0
  24. package/dist/jarvis/memory/store.js +189 -0
  25. package/dist/jarvis/prompt/assemble.d.ts +61 -0
  26. package/dist/jarvis/prompt/assemble.js +118 -0
  27. package/dist/jarvis/prompt/policy.d.ts +22 -0
  28. package/dist/jarvis/prompt/policy.js +80 -0
  29. package/dist/jarvis/prompt/skills.d.ts +69 -0
  30. package/dist/jarvis/prompt/skills.js +257 -0
  31. package/dist/jarvis/react-loop.d.ts +51 -0
  32. package/dist/jarvis/react-loop.js +160 -0
  33. package/dist/jarvis/telemetry/provenance.d.ts +54 -0
  34. package/dist/jarvis/telemetry/provenance.js +56 -0
  35. package/dist/jarvis/test-helpers.d.ts +14 -0
  36. package/dist/jarvis/test-helpers.js +69 -0
  37. package/dist/jarvis/tools/fs-tools.d.ts +28 -0
  38. package/dist/jarvis/tools/fs-tools.js +292 -0
  39. package/dist/jarvis/tools/memory-tools.d.ts +27 -0
  40. package/dist/jarvis/tools/memory-tools.js +83 -0
  41. package/dist/jarvis/tools/registry.d.ts +54 -0
  42. package/dist/jarvis/tools/registry.js +49 -0
  43. package/dist/jarvis/util/confirm.d.ts +28 -0
  44. package/dist/jarvis/util/confirm.js +54 -0
  45. package/dist/jarvis/util/kill.d.ts +10 -0
  46. package/dist/jarvis/util/kill.js +39 -0
  47. package/dist/jarvis/util/text.d.ts +74 -0
  48. package/dist/jarvis/util/text.js +190 -0
  49. package/dist/program.d.ts +2 -0
  50. package/dist/program.js +12 -0
  51. package/package.json +35 -3
@@ -0,0 +1,122 @@
1
+ // xcoder:version 2 | xcoder:task task-482677 | xcoder:updated 2026-11-05T03:48:55.859Z | xcoder:subtask code-st-729fd8
2
+ import { EXIT, runJarvisAgent } from "../agent.js";
3
+ import { readKillState } from "../util/kill.js";
4
+ import { createChatConfirm } from "./confirm.js";
5
+ import { createStdinLineSource } from "./lines.js";
6
+ import { CHAT_DIRECTIVE } from "./prompt.js";
7
+ import { isIgnorable, isTerminator } from "./terminator.js";
8
+ /**
9
+ * Build a per-turn argv from the already-validated request. It is re-parsed by
10
+ * `runJarvisAgent`, so every turn passes through the same single validation
11
+ * gate instead of a second, divergent parser.
12
+ */
13
+ export function turnArgv(request, session, text) {
14
+ const argv = [];
15
+ for (const skill of request.skills) {
16
+ argv.push("--skill", skill);
17
+ }
18
+ argv.push("--max-iterations", String(request.maxIterations));
19
+ argv.push("--session", session);
20
+ if (request.user !== undefined)
21
+ argv.push("--user", request.user);
22
+ if (!request.memory)
23
+ argv.push("--no-memory");
24
+ if (request.json)
25
+ argv.push("--json");
26
+ // `--` keeps a message that happens to start with `--` a positional word.
27
+ argv.push("--", text);
28
+ return argv;
29
+ }
30
+ export async function runChatSession(request, deps = {}) {
31
+ const stderr = deps.stderr ?? process.stderr;
32
+ const workspaceRoot = deps.workspaceRoot ?? process.cwd();
33
+ const result = await driveChat(request, deps, workspaceRoot, stderr);
34
+ return result.exitCode;
35
+ }
36
+ export async function runChatSessionDetailed(request, deps = {}) {
37
+ const stderr = deps.stderr ?? process.stderr;
38
+ const workspaceRoot = deps.workspaceRoot ?? process.cwd();
39
+ return await driveChat(request, deps, workspaceRoot, stderr);
40
+ }
41
+ async function driveChat(request, deps, workspaceRoot, stderr) {
42
+ // A stable session label is what makes the turns one conversation.
43
+ const session = request.session ?? `jarvis-chat-${Date.now().toString(36)}`;
44
+ const lines = deps.lines ?? createStdinLineSource({ stdin: deps.stdin });
45
+ const isInteractive = deps.isInteractive ?? Boolean((deps.stdin ?? process.stdin).isTTY);
46
+ const chatConfirm = createChatConfirm({ lines, stdout: stderr, isInteractive });
47
+ const turnDeps = {
48
+ ...deps,
49
+ stderr,
50
+ // Only this gate answers confirmation prompts in chat, so the agent's own
51
+ // readline-based createConfirm is unreachable here (AC20).
52
+ confirm: deps.confirm ?? chatConfirm,
53
+ // Marks the turn: constant trusted directive + user line appended to
54
+ // memory before the run (T4/AC15).
55
+ chatTurn: true,
56
+ };
57
+ let turns = 0;
58
+ if (!request.json) {
59
+ // --json must stay machine-readable, so the banner is never on stdout.
60
+ tryWrite(stderr, `jarvis: chat session ${session} - type exit, quit or bye to leave\n`);
61
+ }
62
+ try {
63
+ for (;;) {
64
+ // Kill switch at each turn boundary (K7/AC18/AC19).
65
+ if (readKillState(workspaceRoot).killed) {
66
+ tryWrite(stderr, "jarvis: aborted - kill switch tripped\n");
67
+ return { exitCode: EXIT.aborted, stopReason: "killed", turns };
68
+ }
69
+ // A terminator typed at a confirmation prompt denies that tool and ends
70
+ // the session here, at a turn boundary, not mid-turn (T5).
71
+ if (chatConfirm.pendingExit) {
72
+ return { exitCode: EXIT.answered, stopReason: "terminator", turns };
73
+ }
74
+ if (turns >= request.maxTurns) {
75
+ tryWrite(stderr, `jarvis: --max-turns ${request.maxTurns} reached\n`);
76
+ return { exitCode: EXIT.budget_exceeded, stopReason: "max_turns", turns };
77
+ }
78
+ const next = await lines.next();
79
+ if (next.kind === "eof") {
80
+ return { exitCode: EXIT.answered, stopReason: "eof", turns };
81
+ }
82
+ if (next.kind === "too_long") {
83
+ // Rejected before the model sees it. Only the size is echoed: the line
84
+ // itself may contain anything and must not be mirrored into a log.
85
+ tryWrite(stderr, `jarvis: message rejected - ${next.bytes} bytes exceeds the limit\n`);
86
+ continue;
87
+ }
88
+ if (isIgnorable(next.text))
89
+ continue;
90
+ if (isTerminator(next.text)) {
91
+ return { exitCode: EXIT.answered, stopReason: "terminator", turns };
92
+ }
93
+ const code = await runJarvisAgent(turnArgv(request, session, next.text), turnDeps);
94
+ turns += 1;
95
+ if (code === EXIT.aborted) {
96
+ return { exitCode: EXIT.aborted, stopReason: "killed", turns };
97
+ }
98
+ if (chatConfirm.pendingExit) {
99
+ return { exitCode: EXIT.answered, stopReason: "terminator", turns };
100
+ }
101
+ }
102
+ }
103
+ finally {
104
+ // Idempotent by contract, and it must run on every exit path.
105
+ try {
106
+ await lines.close();
107
+ }
108
+ catch {
109
+ /* closing twice is not an error */
110
+ }
111
+ }
112
+ }
113
+ function tryWrite(stream, text) {
114
+ try {
115
+ stream.write(text);
116
+ }
117
+ catch {
118
+ /* a closed stream must not fail the session */
119
+ }
120
+ }
121
+ /** Re-exported so a prompt test can pin the constant directive (AC15). */
122
+ export { CHAT_DIRECTIVE };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Loop-terminator predicates for `jarvis --chat` (design K8, AC7/AC8).
3
+ *
4
+ * The match is WHOLE-LINE, trimmed and case-insensitive on purpose. A
5
+ * substring test would make "exit now" or "how do I quit a program?" end the
6
+ * session, which is exactly the surprising behaviour the design forbids: only
7
+ * a line that is nothing but a terminator stops the loop.
8
+ */
9
+ export declare const TERMINATORS: readonly ["exit", "quit", "bye"];
10
+ export type TerminatorWord = (typeof TERMINATORS)[number];
11
+ /** True only when the whole line, trimmed and lowercased, is a terminator. */
12
+ export declare function isTerminator(line: string): boolean;
13
+ /** True for an empty or all-whitespace line, which must be fully inert. */
14
+ export declare function isIgnorable(line: string): boolean;
@@ -0,0 +1,19 @@
1
+ // xcoder:version 1 | xcoder:task task-482677 | xcoder:updated 2026-11-05T03:46:06.874Z | xcoder:subtask code-st-729fd8
2
+ /**
3
+ * Loop-terminator predicates for `jarvis --chat` (design K8, AC7/AC8).
4
+ *
5
+ * The match is WHOLE-LINE, trimmed and case-insensitive on purpose. A
6
+ * substring test would make "exit now" or "how do I quit a program?" end the
7
+ * session, which is exactly the surprising behaviour the design forbids: only
8
+ * a line that is nothing but a terminator stops the loop.
9
+ */
10
+ export const TERMINATORS = ["exit", "quit", "bye"];
11
+ const TERMINATOR_SET = new Set(TERMINATORS);
12
+ /** True only when the whole line, trimmed and lowercased, is a terminator. */
13
+ export function isTerminator(line) {
14
+ return TERMINATOR_SET.has(line.trim().toLowerCase());
15
+ }
16
+ /** True for an empty or all-whitespace line, which must be fully inert. */
17
+ export function isIgnorable(line) {
18
+ return line.trim().length === 0;
19
+ }
@@ -0,0 +1,64 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The model's reply, parsed into a typed decision (PRM-07, PRM-08, LLM02).
4
+ *
5
+ * Nothing downstream ever sees raw model text: a reply becomes a
6
+ * `ReactDecision` or it becomes an error. That is what keeps a hostile or
7
+ * merely sloppy completion out of shells, SQL, eval, HTML and file paths —
8
+ * the only reachable destination for model output is a typed descriptor
9
+ * lookup plus that tool's own args schema.
10
+ */
11
+ export declare const MAX_THOUGHT = 4000;
12
+ export declare const MAX_ANSWER = 20000;
13
+ export declare const MAX_ARGS = 12;
14
+ export declare const MAX_RAW = 100000;
15
+ /** Tool names: lowercase, 3-40 chars — a closed, unremarkable namespace. */
16
+ export declare const TOOL_NAME_RE: RegExp;
17
+ export declare const FinalDecision: z.ZodObject<{
18
+ kind: z.ZodLiteral<"final">;
19
+ thought: z.ZodString;
20
+ answer: z.ZodString;
21
+ }, z.core.$strip>;
22
+ export declare const ActionDecision: z.ZodObject<{
23
+ kind: z.ZodLiteral<"action">;
24
+ thought: z.ZodString;
25
+ tool: z.ZodString;
26
+ args: z.ZodRecord<z.ZodString, z.ZodUnknown>;
27
+ }, z.core.$strip>;
28
+ export declare const ReactDecision: z.ZodDiscriminatedUnion<[z.ZodObject<{
29
+ kind: z.ZodLiteral<"final">;
30
+ thought: z.ZodString;
31
+ answer: z.ZodString;
32
+ }, z.core.$strip>, z.ZodObject<{
33
+ kind: z.ZodLiteral<"action">;
34
+ thought: z.ZodString;
35
+ tool: z.ZodString;
36
+ args: z.ZodRecord<z.ZodString, z.ZodUnknown>;
37
+ }, z.core.$strip>], "kind">;
38
+ export type ReactDecision = z.infer<typeof ReactDecision>;
39
+ export type FinalDecision = z.infer<typeof FinalDecision>;
40
+ export type ActionDecision = z.infer<typeof ActionDecision>;
41
+ export declare class ReactParseError extends Error {
42
+ readonly stage: "extract" | "json" | "schema";
43
+ readonly detail?: string | undefined;
44
+ constructor(message: string, stage: "extract" | "json" | "schema", detail?: string | undefined);
45
+ }
46
+ /**
47
+ * Find the first balanced `{...}` region, ignoring braces inside JSON strings.
48
+ *
49
+ * Models wrap JSON in prose or fences routinely, so we cannot require the
50
+ * reply to be pure JSON. We also cannot use a greedy regex: `{...}{...}`
51
+ * would swallow both objects. Returns undefined when no balanced region
52
+ * exists.
53
+ */
54
+ export declare function extractJsonObject(raw: string): string | undefined;
55
+ /**
56
+ * Parse a model reply into a decision. Throws `ReactParseError`.
57
+ *
58
+ * Three distinct failure stages are reported so the corrective nudge can be
59
+ * specific ("that was not JSON" vs "that JSON was the wrong shape") without
60
+ * ever echoing the model's own text back into the prompt as an instruction.
61
+ */
62
+ export declare function parseDecision(raw: string): ReactDecision;
63
+ /** True when `name` is a syntactically legal tool name. */
64
+ export declare function isValidToolName(name: string): boolean;
@@ -0,0 +1,116 @@
1
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:45:07.079Z | xcoder:subtask code-st-53c7b5
2
+ import { z } from "zod";
3
+ /**
4
+ * The model's reply, parsed into a typed decision (PRM-07, PRM-08, LLM02).
5
+ *
6
+ * Nothing downstream ever sees raw model text: a reply becomes a
7
+ * `ReactDecision` or it becomes an error. That is what keeps a hostile or
8
+ * merely sloppy completion out of shells, SQL, eval, HTML and file paths —
9
+ * the only reachable destination for model output is a typed descriptor
10
+ * lookup plus that tool's own args schema.
11
+ */
12
+ export const MAX_THOUGHT = 4_000;
13
+ export const MAX_ANSWER = 20_000;
14
+ export const MAX_ARGS = 12;
15
+ export const MAX_RAW = 100_000;
16
+ /** Tool names: lowercase, 3-40 chars — a closed, unremarkable namespace. */
17
+ export const TOOL_NAME_RE = /^[a-z][a-z0-9_]{2,39}$/;
18
+ export const FinalDecision = z.object({
19
+ kind: z.literal("final"),
20
+ thought: z.string().max(MAX_THOUGHT),
21
+ answer: z.string().min(1).max(MAX_ANSWER),
22
+ });
23
+ export const ActionDecision = z.object({
24
+ kind: z.literal("action"),
25
+ thought: z.string().max(MAX_THOUGHT),
26
+ tool: z.string().regex(TOOL_NAME_RE),
27
+ args: z.record(z.string(), z.unknown()).refine((r) => Object.keys(r).length <= MAX_ARGS, {
28
+ message: `args must have at most ${MAX_ARGS} keys`,
29
+ }),
30
+ });
31
+ export const ReactDecision = z.discriminatedUnion("kind", [FinalDecision, ActionDecision]);
32
+ export class ReactParseError extends Error {
33
+ stage;
34
+ detail;
35
+ constructor(message, stage, detail) {
36
+ super(message);
37
+ this.stage = stage;
38
+ this.detail = detail;
39
+ this.name = "ReactParseError";
40
+ }
41
+ }
42
+ /**
43
+ * Find the first balanced `{...}` region, ignoring braces inside JSON strings.
44
+ *
45
+ * Models wrap JSON in prose or fences routinely, so we cannot require the
46
+ * reply to be pure JSON. We also cannot use a greedy regex: `{...}{...}`
47
+ * would swallow both objects. Returns undefined when no balanced region
48
+ * exists.
49
+ */
50
+ export function extractJsonObject(raw) {
51
+ const text = raw.length > MAX_RAW ? raw.slice(0, MAX_RAW) : raw;
52
+ const start = text.indexOf("{");
53
+ if (start === -1)
54
+ return undefined;
55
+ let depth = 0;
56
+ let inString = false;
57
+ let escaped = false;
58
+ for (let i = start; i < text.length; i += 1) {
59
+ const ch = text[i];
60
+ if (inString) {
61
+ if (escaped)
62
+ escaped = false;
63
+ else if (ch === "\\")
64
+ escaped = true;
65
+ else if (ch === '"')
66
+ inString = false;
67
+ continue;
68
+ }
69
+ if (ch === '"') {
70
+ inString = true;
71
+ continue;
72
+ }
73
+ if (ch === "{")
74
+ depth += 1;
75
+ else if (ch === "}") {
76
+ depth -= 1;
77
+ if (depth === 0)
78
+ return text.slice(start, i + 1);
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+ /**
84
+ * Parse a model reply into a decision. Throws `ReactParseError`.
85
+ *
86
+ * Three distinct failure stages are reported so the corrective nudge can be
87
+ * specific ("that was not JSON" vs "that JSON was the wrong shape") without
88
+ * ever echoing the model's own text back into the prompt as an instruction.
89
+ */
90
+ export function parseDecision(raw) {
91
+ if (typeof raw !== "string" || raw.trim().length === 0) {
92
+ throw new ReactParseError("empty reply", "extract");
93
+ }
94
+ const objectText = extractJsonObject(raw);
95
+ if (!objectText) {
96
+ throw new ReactParseError("no JSON object found in reply", "extract");
97
+ }
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(objectText);
101
+ }
102
+ catch (err) {
103
+ throw new ReactParseError("reply contained invalid JSON", "json", err instanceof Error ? err.message.slice(0, 200) : undefined);
104
+ }
105
+ const result = ReactDecision.safeParse(parsed);
106
+ if (!result.success) {
107
+ const first = result.error.issues[0];
108
+ const where = first?.path.join(".") || "(root)";
109
+ throw new ReactParseError(`reply did not match the decision schema at ${where}`, "schema", first?.message.slice(0, 200));
110
+ }
111
+ return result.data;
112
+ }
113
+ /** True when `name` is a syntactically legal tool name. */
114
+ export function isValidToolName(name) {
115
+ return TOOL_NAME_RE.test(name);
116
+ }
@@ -0,0 +1,88 @@
1
+ export interface MemoryScope {
2
+ /** sha256(realpath(workspace)).slice(0,16) — stable across runs. */
3
+ workspaceId: string;
4
+ /** Sanitised label from --user, or the OS user. A label, not an identity. */
5
+ userId: string;
6
+ }
7
+ export type MemoryKind = "fact" | "preference" | "task" | "observation";
8
+ export type MemorySource = "user" | "agent" | "tool";
9
+ export interface MemoryEntry {
10
+ v: 1;
11
+ id: string;
12
+ ts: string;
13
+ session: string;
14
+ scope: MemoryScope;
15
+ kind: MemoryKind;
16
+ key?: string;
17
+ value: string;
18
+ source: MemorySource;
19
+ bytes: number;
20
+ }
21
+ export interface AppendInput {
22
+ session: string;
23
+ kind: MemoryKind;
24
+ key?: string;
25
+ value: string;
26
+ source: MemorySource;
27
+ }
28
+ export interface MemoryStoreOptions {
29
+ rootDir: string;
30
+ scope: MemoryScope;
31
+ maxEntries?: number;
32
+ maxBytes?: number;
33
+ windowEntries?: number;
34
+ now?: () => Date;
35
+ }
36
+ export declare const DEFAULT_MAX_ENTRIES = 500;
37
+ export declare const DEFAULT_MAX_BYTES = 1048576;
38
+ export declare const DEFAULT_WINDOW_ENTRIES = 20;
39
+ export declare const MAX_VALUE_CHARS = 2000;
40
+ /** Build a workspace scope id from an absolute, already-resolved path. */
41
+ export declare function workspaceScopeId(realWorkspacePath: string): string;
42
+ export declare function resolveUserId(raw: string | undefined): string;
43
+ /**
44
+ * File-backed, scoped, capped, redacted conversational memory (design §2.9).
45
+ *
46
+ * Three deliberate properties:
47
+ * - Append-only JSONL. A crash can only tear the final line, and a torn line
48
+ * is dropped on read rather than throwing, so memory never blocks startup.
49
+ * - Redaction happens at WRITE time. A secret that reaches memory is scrubbed
50
+ * before it ever lands on disk, so later reads cannot leak it either.
51
+ * - Compaction is deterministic and never calls a model. Model output written
52
+ * back into memory would let one injection persist across sessions, and it
53
+ * would make compaction impossible to test.
54
+ */
55
+ export declare class MemoryStore {
56
+ private readonly rootDir;
57
+ private readonly scope;
58
+ private readonly maxEntries;
59
+ private readonly maxBytes;
60
+ private readonly windowEntries;
61
+ private readonly now;
62
+ /** Redaction hits accumulated by this instance, reported in provenance. */
63
+ private redactionCount;
64
+ constructor(o: MemoryStoreOptions);
65
+ /** workspace/.xcoder/jarvis/memory/[workspaceId]/[userId].jsonl */
66
+ path(): string;
67
+ get scopeValue(): MemoryScope;
68
+ redactions(): number;
69
+ /** Tolerates a torn final line and any line that is not a valid entry. */
70
+ load(): Promise<MemoryEntry[]>;
71
+ append(input: AppendInput): Promise<MemoryEntry>;
72
+ /** Newest `n` entries, oldest-first for prompt readability. */
73
+ window(n?: number): Promise<MemoryEntry[]>;
74
+ /**
75
+ * Deterministic compaction: keep the newest entry per key (pinned), then
76
+ * fill remaining capacity newest-first. Identical input yields identical
77
+ * output, which is what makes it testable.
78
+ */
79
+ compactEntries(entries: readonly MemoryEntry[]): MemoryEntry[];
80
+ compact(): Promise<{
81
+ before: number;
82
+ after: number;
83
+ }>;
84
+ summary(): Promise<string>;
85
+ clear(): Promise<void>;
86
+ private appendLine;
87
+ private writeAll;
88
+ }
@@ -0,0 +1,189 @@
1
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:46:33.019Z | xcoder:subtask code-st-53c7b5
2
+ import crypto from "node:crypto";
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { redact, sanitizeLabel, shortHash, writeAtomic } from "../util/text.js";
6
+ export const DEFAULT_MAX_ENTRIES = 500;
7
+ export const DEFAULT_MAX_BYTES = 1_048_576;
8
+ export const DEFAULT_WINDOW_ENTRIES = 20;
9
+ export const MAX_VALUE_CHARS = 2_000;
10
+ /** Build a workspace scope id from an absolute, already-resolved path. */
11
+ export function workspaceScopeId(realWorkspacePath) {
12
+ return shortHash(realWorkspacePath, 16);
13
+ }
14
+ export function resolveUserId(raw) {
15
+ const candidate = raw?.trim() || process.env["USERNAME"] || process.env["USER"] || "unknown";
16
+ return sanitizeLabel(candidate, "unknown");
17
+ }
18
+ function isValidEntry(v) {
19
+ if (typeof v !== "object" || v === null)
20
+ return false;
21
+ const e = v;
22
+ return (e.v === 1 &&
23
+ typeof e.id === "string" &&
24
+ typeof e.ts === "string" &&
25
+ typeof e.session === "string" &&
26
+ typeof e.value === "string" &&
27
+ typeof e.kind === "string" &&
28
+ typeof e.source === "string" &&
29
+ typeof e.scope === "object" &&
30
+ e.scope !== null);
31
+ }
32
+ /**
33
+ * File-backed, scoped, capped, redacted conversational memory (design §2.9).
34
+ *
35
+ * Three deliberate properties:
36
+ * - Append-only JSONL. A crash can only tear the final line, and a torn line
37
+ * is dropped on read rather than throwing, so memory never blocks startup.
38
+ * - Redaction happens at WRITE time. A secret that reaches memory is scrubbed
39
+ * before it ever lands on disk, so later reads cannot leak it either.
40
+ * - Compaction is deterministic and never calls a model. Model output written
41
+ * back into memory would let one injection persist across sessions, and it
42
+ * would make compaction impossible to test.
43
+ */
44
+ export class MemoryStore {
45
+ rootDir;
46
+ scope;
47
+ maxEntries;
48
+ maxBytes;
49
+ windowEntries;
50
+ now;
51
+ /** Redaction hits accumulated by this instance, reported in provenance. */
52
+ redactionCount = 0;
53
+ constructor(o) {
54
+ this.rootDir = o.rootDir;
55
+ this.scope = o.scope;
56
+ this.maxEntries = o.maxEntries ?? DEFAULT_MAX_ENTRIES;
57
+ this.maxBytes = o.maxBytes ?? DEFAULT_MAX_BYTES;
58
+ this.windowEntries = o.windowEntries ?? DEFAULT_WINDOW_ENTRIES;
59
+ this.now = o.now ?? (() => new Date());
60
+ }
61
+ /** workspace/.xcoder/jarvis/memory/[workspaceId]/[userId].jsonl */
62
+ path() {
63
+ return path.join(this.rootDir, this.scope.workspaceId, `${this.scope.userId}.jsonl`);
64
+ }
65
+ get scopeValue() {
66
+ return this.scope;
67
+ }
68
+ redactions() {
69
+ return this.redactionCount;
70
+ }
71
+ /** Tolerates a torn final line and any line that is not a valid entry. */
72
+ async load() {
73
+ let raw;
74
+ try {
75
+ raw = await fsp.readFile(this.path(), "utf8");
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ const out = [];
81
+ for (const line of raw.split(/\r?\n/)) {
82
+ const trimmed = line.trim();
83
+ if (trimmed.length === 0)
84
+ continue;
85
+ try {
86
+ const parsed = JSON.parse(trimmed);
87
+ if (isValidEntry(parsed))
88
+ out.push(parsed);
89
+ }
90
+ catch {
91
+ // A torn tail (process killed mid-append) is expected; skip it.
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ async append(input) {
97
+ const { text, hits } = redact(input.value.slice(0, MAX_VALUE_CHARS));
98
+ this.redactionCount += hits;
99
+ const entry = {
100
+ v: 1,
101
+ id: `mem-${crypto.randomBytes(4).toString("hex")}`,
102
+ ts: this.now().toISOString(),
103
+ session: input.session,
104
+ scope: this.scope,
105
+ kind: input.kind,
106
+ ...(input.key ? { key: input.key.slice(0, 128) } : {}),
107
+ value: text,
108
+ source: input.source,
109
+ bytes: Buffer.byteLength(text, "utf8"),
110
+ };
111
+ const existing = await this.load();
112
+ const projected = [...existing, entry];
113
+ const projectedBytes = projected.reduce((n, e) => n + e.bytes + 128, 0);
114
+ if (projected.length > this.maxEntries || projectedBytes > this.maxBytes) {
115
+ // Compacting before the append keeps the newest entry without a second pass.
116
+ const compacted = this.compactEntries(projected);
117
+ await this.writeAll(compacted);
118
+ }
119
+ else {
120
+ await this.appendLine(entry);
121
+ }
122
+ return entry;
123
+ }
124
+ /** Newest `n` entries, oldest-first for prompt readability. */
125
+ async window(n) {
126
+ const limit = Math.max(1, n ?? this.windowEntries);
127
+ const all = await this.load();
128
+ return all.slice(-limit);
129
+ }
130
+ /**
131
+ * Deterministic compaction: keep the newest entry per key (pinned), then
132
+ * fill remaining capacity newest-first. Identical input yields identical
133
+ * output, which is what makes it testable.
134
+ */
135
+ compactEntries(entries) {
136
+ const byKey = new Map();
137
+ const unkeyed = [];
138
+ for (const e of entries) {
139
+ if (!e.key) {
140
+ unkeyed.push(e);
141
+ continue;
142
+ }
143
+ const prior = byKey.get(e.key);
144
+ // Later entries win; entries arrive in file order (oldest first).
145
+ if (!prior || prior.ts <= e.ts)
146
+ byKey.set(e.key, e);
147
+ }
148
+ const pinned = [...byKey.values()].sort((a, b) => a.ts.localeCompare(b.ts));
149
+ const budget = Math.max(0, this.maxEntries - pinned.length);
150
+ const tail = unkeyed.slice(-Math.max(0, budget));
151
+ return [...pinned, ...tail].sort((a, b) => a.ts.localeCompare(b.ts));
152
+ }
153
+ async compact() {
154
+ const before = await this.load();
155
+ const after = this.compactEntries(before);
156
+ await this.writeAll(after);
157
+ return { before: before.length, after: after.length };
158
+ }
159
+ async summary() {
160
+ const all = await this.load();
161
+ if (all.length === 0)
162
+ return "memory is empty";
163
+ const kinds = new Map();
164
+ for (const e of all)
165
+ kinds.set(e.kind, (kinds.get(e.kind) ?? 0) + 1);
166
+ const parts = [...kinds.entries()].map(([k, n]) => `${k}=${n}`).join(", ");
167
+ return `${all.length} entries (${parts}); newest ${all[all.length - 1].ts}`;
168
+ }
169
+ async clear() {
170
+ await this.writeAll([]);
171
+ }
172
+ async appendLine(entry) {
173
+ const file = this.path();
174
+ await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
175
+ await fsp.appendFile(file, JSON.stringify(entry) + "\n", { encoding: "utf8", mode: 0o600 });
176
+ if (process.platform !== "win32") {
177
+ try {
178
+ await fsp.chmod(file, 0o600);
179
+ }
180
+ catch {
181
+ /* best effort */
182
+ }
183
+ }
184
+ }
185
+ async writeAll(entries) {
186
+ const body = entries.map((e) => JSON.stringify(e)).join("\n");
187
+ await writeAtomic(this.path(), body.length > 0 ? body + "\n" : "");
188
+ }
189
+ }
@@ -0,0 +1,61 @@
1
+ import type { MemoryEntry } from "../memory/store.js";
2
+ import type { SkillPack } from "./skills.js";
3
+ /**
4
+ * System-prompt assembly with an explicit trust boundary (PRM-02, PRM-05).
5
+ *
6
+ * Order is fixed so the result is testable: trusted policy first, then role
7
+ * framing, then skill packs, then the memory window, then an explicit
8
+ * reminder that the data blocks are data. The policy sha256 is computed at
9
+ * assembly and recorded, so any change to the instruction text shows up in
10
+ * provenance rather than silently altering behaviour.
11
+ *
12
+ * Skill and memory text is normalised (zero-width stripped) and any literal
13
+ * closing delimiter is entity-escaped, so a pack cannot end its own block and
14
+ * promote the remainder to instructions.
15
+ */
16
+ export declare const UNTRUSTED_SKILL_TAG = "untrusted-skill";
17
+ export declare const UNTRUSTED_MEMORY_TAG = "untrusted-memory";
18
+ export declare const UNTRUSTED_OBSERVATION_TAG = "untrusted-observation";
19
+ /** The dna role pulled in for framing; imported, never copied. */
20
+ export declare const JARVIS_ROLE: "programmer";
21
+ export interface PromptProvenance {
22
+ policyVersion: string;
23
+ policySha256: string;
24
+ agentName: string;
25
+ skills: Array<{
26
+ name: string;
27
+ sha256: string;
28
+ bytes: number;
29
+ }>;
30
+ skippedSkills: Array<{
31
+ name: string;
32
+ reason: string;
33
+ }>;
34
+ memoryWindowEntries: number;
35
+ toolAllowlist: readonly string[];
36
+ assembledAt: string;
37
+ }
38
+ export interface AssembledPrompt {
39
+ system: string;
40
+ provenance: PromptProvenance;
41
+ }
42
+ export interface AssembleInput {
43
+ agentName: string;
44
+ directive: string;
45
+ skills: readonly SkillPack[];
46
+ window: readonly MemoryEntry[];
47
+ toolAllowlist: readonly string[];
48
+ toolManifest: string;
49
+ skippedSkills?: ReadonlyArray<{
50
+ name: string;
51
+ reason: string;
52
+ }>;
53
+ now: Date;
54
+ }
55
+ export declare function assembleSystemPrompt(a: AssembleInput): AssembledPrompt;
56
+ /**
57
+ * Wrap a tool observation for the transcript. Observations are attacker-
58
+ * influenceable (they contain file contents), so they travel as data under
59
+ * the same delimiter rules as skill packs.
60
+ */
61
+ export declare function wrapObservation(tool: string, summary: string): string;