factory-ai 1.3.0 → 1.5.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 (65) hide show
  1. package/CHANGELOG.md +24 -4
  2. package/Dockerfile.worker +1 -1
  3. package/README.md +10 -2
  4. package/ROADMAP.md +0 -1
  5. package/bin/factory +130 -3
  6. package/bootstrap/agent-factory-worker.service +3 -3
  7. package/bootstrap/auto-update.sh +20 -12
  8. package/bootstrap/deploy-runtime.sh +7 -3
  9. package/bootstrap/factory-ai-container-firewall.service +14 -0
  10. package/bootstrap/factory-ai-ollama.service +16 -0
  11. package/bootstrap/factory-ai-qdrant.service +16 -0
  12. package/bootstrap/factory-ai-snapshot.service +1 -0
  13. package/bootstrap/factory-ai-watchdog.service +21 -0
  14. package/bootstrap/factory-ai-watchdog.timer +11 -0
  15. package/bootstrap/setup.sh +33 -8
  16. package/cmd/factory-ui/main.go +455 -0
  17. package/go.mod +39 -0
  18. package/go.sum +82 -0
  19. package/package.json +7 -3
  20. package/scripts/test.js +7 -0
  21. package/src/acp-adapter.js +26 -0
  22. package/src/acp-cli.js +16 -0
  23. package/src/activity.js +96 -0
  24. package/src/agent-executor.js +47 -3
  25. package/src/agent-runner.js +34 -12
  26. package/src/approval-policy.js +12 -0
  27. package/src/azure-harness.js +47 -14
  28. package/src/bedrock-harness.js +45 -14
  29. package/src/config.js +13 -0
  30. package/src/container-runner.js +65 -12
  31. package/src/context-compaction.js +16 -0
  32. package/src/control-plane.js +77 -16
  33. package/src/control-service.js +6 -1
  34. package/src/dashboard.js +25 -6
  35. package/src/extension-cli.js +11 -0
  36. package/src/extension-manifest.js +76 -0
  37. package/src/hooks.js +62 -0
  38. package/src/instructions.js +45 -0
  39. package/src/mcp-tools.js +19 -3
  40. package/src/objective-status.js +16 -0
  41. package/src/operator-logs.js +24 -0
  42. package/src/operator.js +34 -10
  43. package/src/process.js +3 -3
  44. package/src/release-gate.js +1 -0
  45. package/src/release-service.js +4 -1
  46. package/src/release.js +1 -1
  47. package/src/repo-map.js +128 -0
  48. package/src/reporter.js +12 -1
  49. package/src/retriever.js +154 -0
  50. package/src/routing.js +11 -1
  51. package/src/scanner-suite.js +4 -3
  52. package/src/setup-menu.js +3 -1
  53. package/src/snapshot.js +16 -0
  54. package/src/task-entry.js +28 -6
  55. package/src/task-graph.js +0 -5
  56. package/src/telegram-service.js +10 -4
  57. package/src/telegram.js +7 -3
  58. package/src/telemetry.js +173 -0
  59. package/src/tui.js +31 -10
  60. package/src/validation.js +41 -9
  61. package/src/watchdog.js +62 -0
  62. package/src/worker.js +34 -3
  63. package/src/workspace-tools.js +13 -3
  64. package/src/workspace.js +22 -2
  65. package/templates/AGENTS.md +15 -0
package/src/mcp-tools.js CHANGED
@@ -3,6 +3,16 @@ function renderContent(result) {
3
3
  return value.length > 32_000 ? `${value.slice(0, 32_000)}\n[TRUNCATED: request narrower MCP output]` : value;
4
4
  }
5
5
 
6
+ async function withTimeout(operation, timeoutMs, label) {
7
+ let timer;
8
+ try {
9
+ return await Promise.race([
10
+ operation,
11
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); timer.unref?.(); }),
12
+ ]);
13
+ } finally { clearTimeout(timer); }
14
+ }
15
+
6
16
  async function defaultConnect(capability) {
7
17
  const [{ Client }, { StdioClientTransport }] = await Promise.all([
8
18
  import("@modelcontextprotocol/sdk/client/index.js"),
@@ -29,17 +39,23 @@ export async function connectMcpTools(capabilities, { connect = defaultConnect }
29
39
  const clients = [];
30
40
  const tools = {};
31
41
  try {
32
- for (const capability of capabilities.filter((item) => item.type === "mcp")) {
42
+ const mcp = capabilities.filter((item) => item.type === "mcp");
43
+ const connected = await Promise.allSettled(mcp.map(async (capability) => {
33
44
  const client = await connect(capability);
34
- clients.push(client);
35
45
  const listed = await client.listTools();
46
+ return { capability, client, listed };
47
+ }));
48
+ for (const result of connected) if (result.status === "fulfilled") clients.push(result.value.client);
49
+ const failure = connected.find((result) => result.status === "rejected");
50
+ if (failure) throw failure.reason;
51
+ for (const { capability, client, listed } of connected.map((result) => result.value)) {
36
52
  for (const definition of listed.tools) {
37
53
  const name = `${capability.name}__${definition.name}`;
38
54
  if (tools[name]) throw new Error(`Duplicate MCP tool: ${name}`);
39
55
  tools[name] = {
40
56
  description: definition.description ?? `${capability.name} ${definition.name}`,
41
57
  parameters: definition.inputSchema ?? { type: "object" },
42
- execute: async (argumentsValue) => renderContent(await client.callTool({ name: definition.name, arguments: argumentsValue })),
58
+ execute: async (argumentsValue) => renderContent(await withTimeout(client.callTool({ name: definition.name, arguments: argumentsValue }), capability.timeoutMs ?? 60_000, `MCP tool ${name}`)),
43
59
  };
44
60
  }
45
61
  }
@@ -0,0 +1,16 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const IDENTIFIER = /^[A-Za-z0-9_-]{1,64}$/;
5
+ const TERMINAL = new Set(["complete", "failed", "blocked", "cancelled", "denied", "expired"]);
6
+
7
+ export async function objectiveIsTerminal(stateDir, objectiveId) {
8
+ if (!IDENTIFIER.test(objectiveId ?? "")) return false;
9
+ try {
10
+ const state = JSON.parse(await readFile(path.join(stateDir, objectiveId, "state.json"), "utf8"));
11
+ return TERMINAL.has(state.status);
12
+ } catch (error) {
13
+ if (error.code === "ENOENT" || error instanceof SyntaxError) return false;
14
+ throw error;
15
+ }
16
+ }
@@ -0,0 +1,24 @@
1
+ const ALLOWED_FIELDS = new Set(["timestamp", "level", "event", "objectiveId", "taskId", "type", "messageId", "source", "deliveryCount", "error", "signal", "queue", "concurrency"]);
2
+
3
+ function redact(value) {
4
+ return String(value)
5
+ .replaceAll(/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{16,}|AKIA[A-Z0-9]{16}|xox[a-z]-[A-Za-z0-9-]{10,})\b/g, "[REDACTED]")
6
+ .replaceAll(/\bbot\d+:[A-Za-z0-9_-]{20,}\b/g, "bot[REDACTED]")
7
+ .replaceAll(/((?:api[_-]?key|token|secret|password|authorization)\s*[=:]\s*)\S+/gi, "$1[REDACTED]")
8
+ .slice(0, 2000);
9
+ }
10
+
11
+ export function safeOperatorLogs(raw, { maxCharacters = 250_000 } = {}) {
12
+ const output = [];
13
+ for (const line of String(raw).split("\n")) {
14
+ let parsed;
15
+ try { parsed = JSON.parse(line); } catch { continue; }
16
+ if (!parsed || typeof parsed !== "object" || typeof parsed.event !== "string") continue;
17
+ const safe = {};
18
+ for (const [key, value] of Object.entries(parsed)) {
19
+ if (ALLOWED_FIELDS.has(key) && ["string", "number", "boolean"].includes(typeof value)) safe[key] = typeof value === "string" ? redact(value) : value;
20
+ }
21
+ output.push(JSON.stringify(safe));
22
+ }
23
+ return output.join("\n").slice(-maxCharacters);
24
+ }
package/src/operator.js CHANGED
@@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url";
4
4
  import { gunzipSync } from "node:zlib";
5
5
  import { DefaultAzureCredential } from "@azure/identity";
6
6
  import { BlobServiceClient } from "@azure/storage-blob";
7
+ import { ServiceBusClient } from "@azure/service-bus";
8
+ import { sendMessage } from "./bus.js";
7
9
  import { run } from "./process.js";
8
10
 
9
11
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -24,6 +26,8 @@ export function createOperator(environment = process.env) {
24
26
  const namespace = environment.FACTORY_SERVICE_BUS ?? "";
25
27
  const vault = environment.FACTORY_KEY_VAULT ?? "";
26
28
  const storageAccount = environment.FACTORY_STORAGE_ACCOUNT ?? "";
29
+ const factoryName = environment.FACTORY_NAME ?? "Factory AI";
30
+ const factoryPurpose = environment.FACTORY_PURPOSE ?? "Ship secure reviewed software continuously";
27
31
  const remote = async (script) => {
28
32
  for (let attempt = 0; attempt < 6; attempt += 1) {
29
33
  try { return extractRunCommand(await command("az", ["vm", "run-command", "invoke", "--resource-group", resourceGroup, "--name", vm, "--command-id", "RunShellScript", "--scripts", script, "--query", "value[0].message", "--output", "tsv"])); }
@@ -34,6 +38,16 @@ export function createOperator(environment = process.env) {
34
38
  }
35
39
  throw new Error("Azure Run Command remained busy");
36
40
  };
41
+ const downloadOperatorBlob = async (name) => {
42
+ if (!storageAccount) return null;
43
+ try {
44
+ const service = new BlobServiceClient(`https://${storageAccount}.blob.core.windows.net`, new DefaultAzureCredential());
45
+ return await service.getContainerClient("operator").getBlockBlobClient(name).downloadToBuffer();
46
+ } catch (error) {
47
+ if (["BlobNotFound", "ContainerNotFound"].includes(error.code)) return null;
48
+ throw error;
49
+ }
50
+ };
37
51
  const withVault = async (operation) => {
38
52
  const ip = await command("curl", ["-fsS", "https://api.ipify.org"]);
39
53
  await command("az", ["keyvault", "network-rule", "add", "--name", vault, "--ip-address", `${ip}/32`, "--output", "none"]);
@@ -45,18 +59,16 @@ export function createOperator(environment = process.env) {
45
59
  return {
46
60
  dashboard: async () => {
47
61
  if (storageAccount) {
48
- try {
49
- const service = new BlobServiceClient(`https://${storageAccount}.blob.core.windows.net`, new DefaultAzureCredential());
50
- const value = await service.getContainerClient("operator").getBlockBlobClient("dashboard.json").downloadToBuffer();
51
- return JSON.parse(value.toString("utf8"));
52
- } catch (error) {
53
- if (!["BlobNotFound", "ContainerNotFound"].includes(error.code)) throw error;
54
- }
62
+ const value = await downloadOperatorBlob("dashboard.json");
63
+ if (value) return JSON.parse(value.toString("utf8"));
55
64
  }
56
65
  const encoded = await remote("sudo -u factory env $(xargs < /etc/agent-factory-control.env) node /opt/agent-factory/app/src/dashboard.js --json | gzip -c | base64 -w0");
57
66
  return JSON.parse(gunzipSync(Buffer.from(encoded, "base64")).toString("utf8"));
58
67
  },
59
- logs: async () => remote('journalctl -u agent-factory-control -u agent-factory-worker -u agent-factory-release --since "1 hour ago" --no-pager -n 300'),
68
+ logs: async () => {
69
+ const value = await downloadOperatorBlob("logs.txt");
70
+ return value ? value.toString("utf8") : remote('journalctl -u agent-factory-control -u agent-factory-worker -u agent-factory-release --since "1 hour ago" --no-pager -n 300');
71
+ },
60
72
  submit: async (repository, objective) => {
61
73
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || objective.trim().length < 3) throw new Error("Valid repository and objective are required");
62
74
  const output = await command(path.join(root, "bin/factory"), ["submit", repository, objective]);
@@ -67,9 +79,21 @@ export function createOperator(environment = process.env) {
67
79
  if (!["pause", "resume"].includes(action)) throw new Error("Unsupported control action");
68
80
  return command(path.join(root, "bin/factory"), [action]);
69
81
  },
82
+ approval: async ({ objectiveId, approvalId, decision, reason }) => {
83
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(objectiveId) || !/^[A-Za-z0-9_-]{1,64}$/.test(approvalId) || !["approved", "denied"].includes(decision) || !reason?.trim()) throw new Error("Valid approval decision is required");
84
+ const client = new ServiceBusClient(namespace.includes(".") ? namespace : `${namespace}.servicebus.windows.net`, new DefaultAzureCredential());
85
+ const sender = client.createSender("control-events");
86
+ const messageId = `approval-${objectiveId}-${approvalId}-${decision}`.slice(0, 64);
87
+ try { await sendMessage(sender, { type: "approval_decision", objectiveId, approvalId, decision, actor: "local-operator", reason: reason.trim().slice(0, 1000), decidedAt: new Date().toISOString(), messageId }, messageId, objectiveId); }
88
+ finally { await sender.close(); await client.close(); }
89
+ },
70
90
  capabilities: async () => JSON.parse(await readFile(path.join(root, "config/capabilities.json"), "utf8")),
71
- config: () => ({ resourceGroup, vm, namespace, vault, storageAccount, models: { scout: "GPT-5.4 nano", simpleBuilder: "Kimi K2.7-Code", builder: "GPT-5.5", tester: "GPT-5.4", critical: "GPT-5.6" } }),
72
- listSecrets: async () => withVault(async () => JSON.parse(await command("az", ["keyvault", "secret", "list", "--vault-name", vault, "--query", "[].{name:name,updated:attributes.updated}", "--output", "json"]))),
91
+ config: () => ({ factoryName, factoryPurpose, resourceGroup, vm, namespace, vault, storageAccount, models: { scout: "GPT-5.4 nano", simpleBuilder: "Kimi K2.7-Code", builder: "GPT-5.5", tester: "GPT-5.4", critical: "GPT-5.6" } }),
92
+ listSecrets: async () => {
93
+ const dashboard = await downloadOperatorBlob("dashboard.json");
94
+ if (dashboard) return JSON.parse(dashboard.toString("utf8")).secrets ?? [];
95
+ return withVault(async () => JSON.parse(await command("az", ["keyvault", "secret", "list", "--vault-name", vault, "--query", "[].{name:name,updated:attributes.updated}", "--output", "json"])));
96
+ },
73
97
  setSecret: async (name, value) => withVault(async () => {
74
98
  if (!/^[A-Za-z0-9-]{1,127}$/.test(name) || !value) throw new Error("Valid secret name and value are required");
75
99
  await command("az", ["keyvault", "secret", "set", "--vault-name", vault, "--name", name, "--value", value, "--output", "none"]);
package/src/process.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
3
  export function run(command, args, options = {}) {
4
- const { cwd, env, inheritEnv = true, timeoutMs = 300_000, input, allowExitCodes = [0], maxOutputBytes = 10_000_000 } = options;
4
+ const { cwd, env, inheritEnv = true, timeoutMs = 300_000, input, allowExitCodes = [0], maxOutputBytes = 10_000_000, onStdout, onStderr } = options;
5
5
  return new Promise((resolve, reject) => {
6
6
  const child = spawn(command, args, {
7
7
  cwd,
@@ -27,8 +27,8 @@ export function run(command, args, options = {}) {
27
27
  if (stream === "stdout") stdout += chunk;
28
28
  else stderr += chunk;
29
29
  }
30
- child.stdout.on("data", (chunk) => appendOutput("stdout", chunk));
31
- child.stderr.on("data", (chunk) => appendOutput("stderr", chunk));
30
+ child.stdout.on("data", (chunk) => { appendOutput("stdout", chunk); onStdout?.(chunk); });
31
+ child.stderr.on("data", (chunk) => { appendOutput("stderr", chunk); onStderr?.(chunk); });
32
32
  child.on("error", (error) => {
33
33
  clearTimeout(timer);
34
34
  reject(error);
@@ -5,6 +5,7 @@ export function evaluateReleaseGate(tasks, results, facts = {}) {
5
5
  for (const task of tasks.filter((candidate) => APPROVAL_ROLES.has(candidate.role))) {
6
6
  const result = results[task.id];
7
7
  if (result?.status !== "succeeded" || result.approval !== "approved") blockers.push(`${task.role} ${task.id}: ${result?.approval ?? result?.status ?? "missing"}`);
8
+ for (const scan of result?.scannerEvidence ?? []) if (scan.status !== "passed") blockers.push(`${task.role} ${task.id}: ${scan.scanner} ${scan.status}`);
8
9
  }
9
10
  const approvals = facts.approvals ?? blockers.length === 0;
10
11
  return { approved: approvals, blockers, autoMerge: approvals && facts.policyAllows === true && facts.checksPass === true };
@@ -24,7 +24,10 @@ const subscription = bus.receiver.subscribe({
24
24
  await bus.receiver.completeMessage(message);
25
25
  } catch (error) {
26
26
  log("error", "release_failed", { deliveryCount: message.deliveryCount, error: error.message });
27
- if (message.deliveryCount >= config.maxDeliveryCount) await bus.receiver.deadLetterMessage(message, { deadLetterReason: "ReleaseFailed", deadLetterErrorDescription: error.message.slice(0, 4096) });
27
+ if (message.deliveryCount >= config.maxDeliveryCount) {
28
+ if (message.body?.objectiveId) await sendMessage(bus.sender, { type: "failure_result", objectiveId: message.body.objectiveId, taskId: "release", error: `Release failed after ${message.deliveryCount} deliveries: ${error.message}` }, `${message.body.objectiveId}:release-failure:v1`, message.body.objectiveId);
29
+ await bus.receiver.deadLetterMessage(message, { deadLetterReason: "ReleaseFailed", deadLetterErrorDescription: error.message.slice(0, 4096) });
30
+ }
28
31
  else await bus.receiver.abandonMessage(message);
29
32
  }
30
33
  },
package/src/release.js CHANGED
@@ -49,7 +49,7 @@ export class GitHubRelease {
49
49
  "",
50
50
  "Human review and repository branch protections remain authoritative.",
51
51
  ].join("\n");
52
- const title = `[Factory AI] ${task.title}`;
52
+ const title = `[${process.env.FACTORY_NAME ?? "Factory AI"}] ${task.title}`;
53
53
  const create = await this.execute("gh", [
54
54
  "pr", "create", "--base", objective.baseBranch, "--head", branch,
55
55
  "--title", title, "--body", body,
@@ -0,0 +1,128 @@
1
+ import { run } from "./process.js";
2
+
3
+ const sourceGlob = "*.{c,cc,cpp,cs,go,h,hpp,java,js,jsx,php,py,rb,rs,sh,ts,tsx}";
4
+ const excludedGlobs = [
5
+ "!**/.git/**",
6
+ "!**/.next/**",
7
+ "!**/.turbo/**",
8
+ "!**/build/**",
9
+ "!**/coverage/**",
10
+ "!**/dist/**",
11
+ "!**/generated/**",
12
+ "!**/node_modules/**",
13
+ "!**/vendor/**",
14
+ "!**/*.generated.*",
15
+ "!**/*.min.*",
16
+ ];
17
+ const symbolPattern = String.raw`^\s*(?:(?:export|public|private|protected|static)\s+)*(?:async\s+)?(?:class|def|enum|fn|func|function|interface|struct|trait|type|const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*`;
18
+ const stopWords = new Set(["and", "for", "from", "into", "the", "this", "with"]);
19
+
20
+ function objectiveTerms(objective) {
21
+ return [...new Set(String(objective).toLowerCase().match(/[a-z_$][a-z0-9_$-]{2,}/g) ?? [])]
22
+ .filter((term) => !stopWords.has(term))
23
+ .sort();
24
+ }
25
+
26
+ function rgArgs(pattern) {
27
+ return [
28
+ "--line-number", "--no-heading", "--color", "never", "--max-count", "20", "--max-filesize", "256K",
29
+ "--glob", sourceGlob,
30
+ ...excludedGlobs.flatMap((glob) => ["--glob", glob]),
31
+ "--regexp", pattern,
32
+ ".",
33
+ ];
34
+ }
35
+
36
+ async function extract(directory, pattern) {
37
+ if (!pattern) return [];
38
+ const { stdout } = await run("rg", rgArgs(pattern), {
39
+ cwd: directory,
40
+ allowExitCodes: [0, 1],
41
+ timeoutMs: 15_000,
42
+ maxOutputBytes: 5_000_000,
43
+ });
44
+ return stdout.split("\n").flatMap((line) => {
45
+ const match = line.match(/^\.\/(.*?):(\d+):(.*)$/);
46
+ if (!match) return [];
47
+ return [{ path: match[1], startLine: Number(match[2]), endLine: Number(match[2]), content: match[3].trim().slice(0, 500) }];
48
+ });
49
+ }
50
+
51
+ function keyFor(entry) {
52
+ return `${entry.path}:${entry.startLine}:${entry.endLine}`;
53
+ }
54
+
55
+ function rank(entries, terms) {
56
+ const byRange = new Map();
57
+ for (const entry of entries) {
58
+ const path = entry.path.toLowerCase();
59
+ const content = entry.content.toLowerCase();
60
+ const score = 1 + terms.reduce((total, term) => total + (path.includes(term) ? 8 : 0) + (content.includes(term) ? 3 : 0), 0);
61
+ const ranked = { ...entry, score };
62
+ const previous = byRange.get(keyFor(ranked));
63
+ if (!previous || ranked.score > previous.score || (ranked.score === previous.score && ranked.content < previous.content)) byRange.set(keyFor(ranked), ranked);
64
+ }
65
+ return [...byRange.values()].sort((a, b) => b.score - a.score
66
+ || a.path.localeCompare(b.path)
67
+ || a.startLine - b.startLine
68
+ || a.endLine - b.endLine
69
+ || a.content.localeCompare(b.content));
70
+ }
71
+
72
+ function entriesWithinBudget(entries, maxCharacters, heading) {
73
+ let length = `${heading}\n`.length;
74
+ const selected = [];
75
+ for (const entry of entries) {
76
+ const section = `--- ${entry.path}:${entry.startLine}-${entry.endLine} score=${Number(entry.score ?? 0).toFixed(3)} ---\n${entry.content}\n`;
77
+ if (length + section.length > maxCharacters) continue;
78
+ selected.push(entry);
79
+ length += section.length;
80
+ }
81
+ return selected;
82
+ }
83
+
84
+ export function formatRepositoryEntries(entries, maxCharacters, heading = "REPOSITORY MAP (ranked symbols and objective references)") {
85
+ if (maxCharacters <= 0) return "";
86
+ let output = `${heading}\n`;
87
+ for (const entry of entriesWithinBudget(entries, maxCharacters, heading)) {
88
+ output += `--- ${entry.path}:${entry.startLine}-${entry.endLine} score=${Number(entry.score ?? 0).toFixed(3)} ---\n${entry.content}\n`;
89
+ }
90
+ return output.length <= maxCharacters ? output.trimEnd() : output.slice(0, maxCharacters);
91
+ }
92
+
93
+ export async function buildRepositoryMap(directory, objective, { maxCharacters = 8000 } = {}) {
94
+ const terms = objectiveTerms(objective);
95
+ const referencePattern = terms.length ? terms.map((term) => term.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") : "";
96
+ const [symbols, references] = await Promise.all([
97
+ extract(directory, symbolPattern),
98
+ extract(directory, referencePattern),
99
+ ]);
100
+ const ranked = rank([...symbols, ...references], terms);
101
+ const heading = "REPOSITORY MAP (ranked symbols and objective references)";
102
+ const entries = entriesWithinBudget(ranked, maxCharacters, heading);
103
+ return { entries, text: formatRepositoryEntries(entries, maxCharacters) };
104
+ }
105
+
106
+ export function mergeRepositoryContext(repositoryEntries, semanticPoints, maxCharacters = 12_000) {
107
+ const occupied = new Set(repositoryEntries.map(keyFor));
108
+ const byRange = new Map();
109
+ for (const point of semanticPoints) {
110
+ const entry = {
111
+ path: point.payload?.path ?? "",
112
+ startLine: Number(point.payload?.startLine ?? 0),
113
+ endLine: Number(point.payload?.endLine ?? point.payload?.startLine ?? 0),
114
+ content: String(point.payload?.content ?? ""),
115
+ score: Number(point.score ?? 0),
116
+ };
117
+ const key = keyFor(entry);
118
+ const previous = byRange.get(key);
119
+ if (entry.path && !occupied.has(key) && (!previous || entry.score > previous.score || (entry.score === previous.score && entry.content < previous.content))) byRange.set(key, entry);
120
+ }
121
+ const semanticEntries = [...byRange.values()].sort((a, b) => b.score - a.score
122
+ || a.path.localeCompare(b.path)
123
+ || a.startLine - b.startLine
124
+ || a.endLine - b.endLine
125
+ || a.content.localeCompare(b.content));
126
+ const semanticText = formatRepositoryEntries(semanticEntries, maxCharacters, "LOCAL SEMANTIC CONTEXT");
127
+ return { entries: [...repositoryEntries, ...semanticEntries], semanticText };
128
+ }
package/src/reporter.js CHANGED
@@ -12,7 +12,7 @@ process.title = "factory-ai-reporter";
12
12
  function markdown(dashboard) {
13
13
  const objectives = Object.entries(dashboard.summary.objectives).map(([state, count]) => `${state}=${count}`).join(", ") || "none";
14
14
  const cost = dashboard.cost ? `\nAzure month-to-date: ${dashboard.cost.currency} ${dashboard.cost.monthToDate.toFixed(2)} (billing data may be delayed)\n` : "";
15
- return `# Factory AI Hourly Report\n\nGenerated: ${dashboard.generatedAt}\n\nQueue: ${dashboard.queue.active} active, ${dashboard.queue.deadLetter} dead-letter\n${cost}\nObjectives: ${objectives}\n`;
15
+ return `# ${process.env.FACTORY_NAME ?? "Factory AI"} Hourly Report\n\nGenerated: ${dashboard.generatedAt}\n\nQueue: ${dashboard.queue.active} active, ${dashboard.queue.deadLetter} dead-letter\n${cost}\nObjectives: ${objectives}\n`;
16
16
  }
17
17
 
18
18
  async function atomicWrite(file, content) {
@@ -47,6 +47,17 @@ export async function uploadDashboardSnapshot(config, dashboard, {
47
47
  return true;
48
48
  }
49
49
 
50
+ export async function uploadOperatorBlob(config, name, value, contentType = "text/plain; charset=utf-8", {
51
+ credential = new DefaultAzureCredential(),
52
+ createClient = (url, auth) => new BlobServiceClient(url, auth),
53
+ } = {}) {
54
+ if (!config.storageAccount) return false;
55
+ const service = createClient(`https://${config.storageAccount}.blob.core.windows.net`, credential);
56
+ const blob = service.getContainerClient("operator").getBlockBlobClient(name);
57
+ await blob.uploadData(Buffer.from(value), { blobHTTPHeaders: { blobContentType: contentType, blobCacheControl: "no-store" } });
58
+ return true;
59
+ }
60
+
50
61
  async function main() {
51
62
  const root = process.env.FACTORY_STATE_DIR ?? "/opt/agent-factory/state";
52
63
  const config = loadConfig();
@@ -0,0 +1,154 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { run } from "./process.js";
5
+ import { mergeRepositoryContext } from "./repo-map.js";
6
+
7
+ const textExtensions = new Set([".c", ".cc", ".cpp", ".cs", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", ".jsx", ".json", ".md", ".mdx", ".php", ".py", ".rb", ".rs", ".scss", ".sh", ".sql", ".svelte", ".toml", ".ts", ".tsx", ".vue", ".yaml", ".yml"]);
8
+ const ignoredDirectories = new Set([".git", ".next", ".turbo", "build", "coverage", "dist", "node_modules", "vendor"]);
9
+
10
+ export function chunkText(file, value, { linesPerChunk = 120, overlapLines = 20 } = {}) {
11
+ const lines = value.split("\n");
12
+ const chunks = [];
13
+ const step = Math.max(1, linesPerChunk - overlapLines);
14
+ for (let start = 0; start < lines.length; start += step) {
15
+ const content = lines.slice(start, start + linesPerChunk).join("\n").trim();
16
+ if (content) chunks.push({ path: file, startLine: start + 1, endLine: Math.min(lines.length, start + linesPerChunk), content });
17
+ if (start + linesPerChunk >= lines.length) break;
18
+ }
19
+ return chunks;
20
+ }
21
+
22
+ export function formatRetrievedContext(points, maxCharacters = 12_000, repositoryEntries = []) {
23
+ return mergeRepositoryContext(repositoryEntries, points, maxCharacters).semanticText;
24
+ }
25
+
26
+ async function collectFiles(directory, root, output, limits) {
27
+ if (output.length >= limits.maxFiles) return;
28
+ for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
29
+ if (entry.isSymbolicLink?.() || ignoredDirectories.has(entry.name) || output.length >= limits.maxFiles) continue;
30
+ const absolute = path.join(directory, entry.name);
31
+ if (entry.isDirectory()) await collectFiles(absolute, root, output, limits);
32
+ else if (entry.isFile() && textExtensions.has(path.extname(entry.name).toLowerCase())) {
33
+ const metadata = await lstat(absolute);
34
+ if (metadata.size <= limits.maxFileBytes) output.push({ absolute, relative: path.relative(root, absolute) });
35
+ }
36
+ }
37
+ }
38
+
39
+ async function workspaceRevision(directory) {
40
+ const head = (await run("git", ["-C", directory, "rev-parse", "HEAD"])).stdout.trim();
41
+ const status = (await run("git", ["-C", directory, "status", "--porcelain=v1"], { maxOutputBytes: 200_000 })).stdout;
42
+ if (!status) return head;
43
+ const diff = (await run("git", ["-C", directory, "diff", "--binary", "HEAD"], { maxOutputBytes: 2_000_000 })).stdout;
44
+ return `${head}-worktree-${createHash("sha256").update(status).update(diff).digest("hex").slice(0, 16)}`;
45
+ }
46
+
47
+ function pointId(value) {
48
+ const hash = createHash("sha256").update(value).digest("hex");
49
+ return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
50
+ }
51
+
52
+ export class LocalRetriever {
53
+ #locks = new Map();
54
+
55
+ constructor({
56
+ stateDir,
57
+ ollamaUrl = "http://127.0.0.1:11434",
58
+ qdrantUrl = "http://127.0.0.1:6333",
59
+ model = "embeddinggemma",
60
+ collection = "factory_ai_code",
61
+ fetch = globalThis.fetch,
62
+ }) {
63
+ this.stateDir = stateDir;
64
+ this.manifestFile = path.join(stateDir, "retrieval", "manifest.json");
65
+ this.ollamaUrl = ollamaUrl;
66
+ this.qdrantUrl = qdrantUrl;
67
+ this.model = model;
68
+ this.collection = collection;
69
+ this.fetch = fetch;
70
+ }
71
+
72
+ async request(url, options = {}) {
73
+ const response = await this.fetch(url, { ...options, signal: AbortSignal.timeout(120_000) });
74
+ if (!response.ok) throw new Error(`Local retrieval HTTP ${response.status}: ${url}`);
75
+ return response.status === 204 ? {} : response.json();
76
+ }
77
+
78
+ async embed(input) {
79
+ const result = await this.request(`${this.ollamaUrl}/api/embed`, {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ model: this.model, input }),
83
+ });
84
+ return result.embeddings;
85
+ }
86
+
87
+ async loadManifest() {
88
+ try { return JSON.parse(await readFile(this.manifestFile, "utf8")); } catch (error) { if (error.code === "ENOENT") return {}; throw error; }
89
+ }
90
+
91
+ async saveManifest(value) {
92
+ await mkdir(path.dirname(this.manifestFile), { recursive: true, mode: 0o750 });
93
+ const temporary = `${this.manifestFile}.${process.pid}.tmp`;
94
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o640 });
95
+ await rename(temporary, this.manifestFile);
96
+ }
97
+
98
+ async ensureIndexed(directory, repository) {
99
+ const previous = this.#locks.get(repository) ?? Promise.resolve();
100
+ const current = previous.catch(() => {}).then(() => this.#index(directory, repository));
101
+ this.#locks.set(repository, current);
102
+ try { return await current; } finally { if (this.#locks.get(repository) === current) this.#locks.delete(repository); }
103
+ }
104
+
105
+ async #index(directory, repository) {
106
+ const commit = await workspaceRevision(directory);
107
+ const manifest = await this.loadManifest();
108
+ if (manifest[repository]?.[commit]?.model === this.model) return false;
109
+ const files = [];
110
+ await collectFiles(directory, directory, files, { maxFiles: 2000, maxFileBytes: 256_000 });
111
+ const chunks = [];
112
+ for (const file of files) {
113
+ const value = await readFile(file.absolute, "utf8");
114
+ chunks.push(...chunkText(file.relative, value));
115
+ if (chunks.length >= 5000) break;
116
+ }
117
+ const selected = chunks.slice(0, 5000);
118
+ if (selected.length === 0) return false;
119
+ const sample = await this.embed([`${selected[0].path}\n${selected[0].content}`]);
120
+ const vectorSize = sample[0].length;
121
+ const collectionResponse = await this.fetch(`${this.qdrantUrl}/collections/${this.collection}`, { signal: AbortSignal.timeout(30_000) });
122
+ if (collectionResponse.status === 404) {
123
+ await this.request(`${this.qdrantUrl}/collections/${this.collection}`, {
124
+ method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ vectors: { size: vectorSize, distance: "Cosine" } }),
125
+ });
126
+ } else if (!collectionResponse.ok) throw new Error(`Qdrant collection HTTP ${collectionResponse.status}`);
127
+ for (let start = 0; start < selected.length; start += 32) {
128
+ const batch = selected.slice(start, start + 32);
129
+ const remaining = batch.slice(1);
130
+ const embeddings = start === 0
131
+ ? [sample[0], ...(remaining.length ? await this.embed(remaining.map((item) => `${item.path}\n${item.content}`)) : [])]
132
+ : await this.embed(batch.map((item) => `${item.path}\n${item.content}`));
133
+ const points = batch.map((item, index) => ({ id: pointId(`${repository}:${commit}:${item.path}:${item.startLine}`), vector: embeddings[index], payload: { repository, commit, ...item } }));
134
+ await this.request(`${this.qdrantUrl}/collections/${this.collection}/points?wait=true`, {
135
+ method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ points }),
136
+ });
137
+ }
138
+ manifest[repository] = { ...(manifest[repository]?.commit ? {} : manifest[repository]), [commit]: { model: this.model, chunks: selected.length, indexedAt: new Date().toISOString() } };
139
+ await this.saveManifest(manifest);
140
+ return true;
141
+ }
142
+
143
+ async context(directory, repository, query, { limit = 8, maxCharacters = 12_000, repositoryEntries = [] } = {}) {
144
+ await this.ensureIndexed(directory, repository);
145
+ const commit = await workspaceRevision(directory);
146
+ const [vector] = await this.embed([query]);
147
+ const result = await this.request(`${this.qdrantUrl}/collections/${this.collection}/points/query`, {
148
+ method: "POST",
149
+ headers: { "content-type": "application/json" },
150
+ body: JSON.stringify({ query: vector, filter: { must: [{ key: "repository", match: { value: repository } }, { key: "commit", match: { value: commit } }] }, limit, with_payload: true }),
151
+ });
152
+ return formatRetrievedContext(result.result?.points ?? [], maxCharacters, repositoryEntries);
153
+ }
154
+ }
package/src/routing.js CHANGED
@@ -20,10 +20,20 @@ const MODELS = Object.freeze({
20
20
  release: "azureai-textved/gpt-5.6-sol",
21
21
  });
22
22
 
23
+ const PROVIDERS = new Set(["azureai-textved", "azureai-responses", "bedrock"]);
24
+
25
+ export function validateModelRoute(route) {
26
+ if (typeof route !== "string" || route.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(route)) throw new Error("Invalid model route");
27
+ const separator = route.indexOf("/");
28
+ if (separator < 1 || !PROVIDERS.has(route.slice(0, separator))) throw new Error("Unsupported model provider");
29
+ if (separator === route.length - 1) throw new Error("Invalid model route");
30
+ return route;
31
+ }
32
+
23
33
  export function modelForRole(role, environment = process.env) {
24
34
  const model = environment[`FACTORY_MODEL_${role.toUpperCase()}`] ?? MODELS[role];
25
35
  if (!model) throw new Error(`Unknown role: ${role}`);
26
- return model;
36
+ return validateModelRoute(model);
27
37
  }
28
38
 
29
39
  export function modelForTask(task, environment = process.env) {
@@ -20,7 +20,7 @@ const scanners = [
20
20
  {
21
21
  name: "semgrep",
22
22
  image: "semgrep/semgrep@sha256:183a149fb3e9700ab5294a7b4ab0241a826fd046bc8b721062fbea80fdfa438f",
23
- args: ["semgrep", "scan", "--config", "p/default", "--json", "/workspace"],
23
+ args: ["semgrep", "scan", "--error", "--config", "p/default", "--json", "/workspace"],
24
24
  },
25
25
  ];
26
26
 
@@ -37,9 +37,10 @@ export class ScannerSuite {
37
37
  this.execute = execute;
38
38
  }
39
39
 
40
- async scan(directory) {
40
+ async scan(directory, { names } = {}) {
41
41
  const workspace = path.resolve(directory);
42
- return Promise.all(scanners.map(async (scanner) => {
42
+ const selected = names ? scanners.filter((scanner) => names.includes(scanner.name)) : scanners;
43
+ return Promise.all(selected.map(async (scanner) => {
43
44
  const args = [
44
45
  "run", "--rm", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
45
46
  "--pids-limit", "512", "--memory", "4g", "--cpus", "1", "--tmpfs", "/tmp:rw,noexec,nosuid,size=3g",
package/src/setup-menu.js CHANGED
@@ -5,6 +5,8 @@ import { writeFile } from "node:fs/promises";
5
5
  const output = process.argv[2];
6
6
  if (!output) throw new Error("Setup result path is required");
7
7
 
8
+ const factoryName = await input({ message: "What should your factory be called?", default: "Factory AI" });
9
+ const factoryPurpose = await input({ message: "What should this factory build or optimize for?", default: "Ship secure, reviewed software continuously" });
8
10
  const provider = await select({
9
11
  message: "Which model provider should the factory configure?",
10
12
  choices: [
@@ -23,4 +25,4 @@ if (provider !== "azure") {
23
25
  }
24
26
  const deployNow = await confirm({ message: "Deploy and start the runtime after storing credentials?", default: true });
25
27
  const telegram = await confirm({ message: "Enable Telegram remote objective intake?", default: false });
26
- await writeFile(output, `${JSON.stringify({ provider, location, githubOrg, awsRegion, bedrockBuilderModel, deployNow, telegram })}\n`, { mode: 0o600 });
28
+ await writeFile(output, `${JSON.stringify({ factoryName, factoryPurpose, provider, location, githubOrg, awsRegion, bedrockBuilderModel, deployNow, telegram })}\n`, { mode: 0o600 });
package/src/snapshot.js CHANGED
@@ -2,6 +2,11 @@
2
2
  import { aggregateDashboard, loadAzureCost, loadLocalState, loadQueueMetrics } from "./dashboard.js";
3
3
  import { loadConfig } from "./config.js";
4
4
  import { uploadDashboardSnapshot } from "./reporter.js";
5
+ import { uploadOperatorBlob } from "./reporter.js";
6
+ import { DefaultAzureCredential } from "@azure/identity";
7
+ import { SecretClient } from "@azure/keyvault-secrets";
8
+ import { run } from "./process.js";
9
+ import { safeOperatorLogs } from "./operator-logs.js";
5
10
 
6
11
  process.title = "factory-ai-snapshot";
7
12
  const config = loadConfig();
@@ -11,5 +16,16 @@ const queue = await loadQueueMetrics(config);
11
16
  let cost = null;
12
17
  try { cost = await loadAzureCost(config); } catch (error) { loaded.warnings.push(`Cost unavailable: ${error.message}`); }
13
18
  const dashboard = aggregateDashboard({ ...loaded, queue, cost, runtime: { status: "running" } });
19
+ try {
20
+ const secrets = [];
21
+ const client = new SecretClient(config.keyVaultUrl, new DefaultAzureCredential());
22
+ for await (const item of client.listPropertiesOfSecrets()) secrets.push({ name: item.name, updated: item.updatedOn?.toISOString() });
23
+ dashboard.secrets = secrets.sort((left, right) => left.name.localeCompare(right.name));
24
+ } catch (error) { dashboard.warnings.push(`Secret metadata unavailable: ${error.message}`); }
25
+ try {
26
+ const result = await run("journalctl", ["-o", "cat", "-u", "agent-factory-control", "-u", "agent-factory-worker", "-u", "agent-factory-release", "-u", "agent-factory-telegram", "--since", "1 hour ago", "--no-pager", "-n", "500"], { timeoutMs: 30_000, maxOutputBytes: 500_000 });
27
+ const logs = safeOperatorLogs(result.stdout);
28
+ await uploadOperatorBlob(config, "logs.txt", logs);
29
+ } catch (error) { dashboard.warnings.push(`Log snapshot unavailable: ${error.message}`); }
14
30
  await uploadDashboardSnapshot(config, dashboard);
15
31
  process.stdout.write(`${JSON.stringify({ event: "dashboard_snapshot", generatedAt: dashboard.generatedAt })}\n`);