factory-ai 1.2.1 → 1.4.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 CHANGED
@@ -11,6 +11,22 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
11
11
  - Azure and Bedrock provider wizard.
12
12
  - Durable evaluation, analytics, scaling, policy, extension, and recovery roadmap.
13
13
 
14
+ ## [1.4.0] - 2026-07-13
15
+
16
+ ### Added
17
+
18
+ - Free local semantic code retrieval with pinned Ollama embeddings and retained Qdrant vectors.
19
+ - Commit-aware indexing, bounded chunking, top-k context injection, and failure fallback.
20
+ - Modern agent-harness capability matrix and explicit completion criteria.
21
+
22
+ ## [1.3.0] - 2026-07-13
23
+
24
+ ### Added
25
+
26
+ - RBAC-protected Azure Blob dashboard snapshots for lock-free multi-operator TUI access.
27
+ - One-minute snapshot timer and direct local Azure-identity reads.
28
+ - Factory AI process, container, journal, Azure, Bedrock, MCP, Git, and PR identity metadata.
29
+
14
30
  ## [1.2.1] - 2026-07-13
15
31
 
16
32
  ### Fixed
package/README.md CHANGED
@@ -131,6 +131,7 @@ Factory AI minimizes tokens before relying on cheaper models:
131
131
  - File reads are line-ranged and bounded; listings, commands, MCP output, memory, and scanner evidence are truncated with continuation hints.
132
132
  - Read-only roles do not receive write-tool schemas.
133
133
  - Planner memory is compact, repository-scoped, and limited to recent verified events.
134
+ - Ollama generates local embeddings and Qdrant retrieves only top-ranked code chunks, avoiding embedding API charges and whole-repository prompts.
134
135
  - Dashboard and TUI track input, cached-input, and output tokens by model.
135
136
  - The planner is instructed to produce the smallest valid DAG, avoiding duplicate agents.
136
137
 
@@ -255,6 +256,7 @@ npm pack --dry-run
255
256
  | [CONTRIBUTING.md](CONTRIBUTING.md) | Development and verification contract |
256
257
  | [HANDOFF.md](HANDOFF.md) | Team/friend transfer context |
257
258
  | [docs/COMPARISON.md](docs/COMPARISON.md) | Honest comparison with paid alternatives |
259
+ | [docs/HARNESS_PARITY.md](docs/HARNESS_PARITY.md) | Feature parity across modern agent harnesses |
258
260
  | [ROADMAP.md](ROADMAP.md) | Planned platform and ecosystem work |
259
261
  | [GOVERNANCE.md](GOVERNANCE.md) | Decision and release governance |
260
262
  | [SUPPORT.md](SUPPORT.md) | Community support process |
package/bin/factory CHANGED
@@ -9,6 +9,9 @@ RESOURCE_GROUP=${FACTORY_RESOURCE_GROUP:-factory-ai-rg}
9
9
  VM=${FACTORY_VM:-agent-factory-vm}
10
10
  NAMESPACE=${FACTORY_SERVICE_BUS:-}
11
11
  VAULT=${FACTORY_KEY_VAULT:-}
12
+ STORAGE=${FACTORY_STORAGE_ACCOUNT:-}
13
+ FACTORY_NAME=${FACTORY_NAME:-Factory AI}
14
+ FACTORY_PURPOSE=${FACTORY_PURPOSE:-Ship secure reviewed software continuously}
12
15
 
13
16
  remote() {
14
17
  az vm run-command invoke --resource-group "$RESOURCE_GROUP" --name "$VM" \
@@ -31,7 +34,7 @@ usage() {
31
34
  ' pause | resume'
32
35
  }
33
36
 
34
- command=${1:-help}
37
+ command=${1:-ui}
35
38
  shift || true
36
39
  case "$command" in
37
40
  telegram)
@@ -73,7 +76,11 @@ case "$command" in
73
76
  exec "$0" submit "$repo" "$objective"
74
77
  ;;
75
78
  ui)
76
- FACTORY_RESOURCE_GROUP="$RESOURCE_GROUP" FACTORY_VM="$VM" FACTORY_SERVICE_BUS="$NAMESPACE" FACTORY_KEY_VAULT="$VAULT" node "$FACTORY_ROOT/src/tui.js"
79
+ if [[ ! -f $CONFIG_FILE && ! -f $LEGACY_CONFIG_FILE ]]; then
80
+ "$0" setup
81
+ exec "$0" ui
82
+ fi
83
+ FACTORY_RESOURCE_GROUP="$RESOURCE_GROUP" FACTORY_VM="$VM" FACTORY_SERVICE_BUS="$NAMESPACE" FACTORY_KEY_VAULT="$VAULT" FACTORY_STORAGE_ACCOUNT="$STORAGE" FACTORY_NAME="$FACTORY_NAME" FACTORY_PURPOSE="$FACTORY_PURPOSE" node "$FACTORY_ROOT/src/tui.js"
77
84
  ;;
78
85
  setup)
79
86
  for dependency in az gh jq curl ssh-keygen node; do command -v "$dependency" >/dev/null || { printf 'Missing dependency: %s\n' "$dependency" >&2; exit 1; }; done
@@ -81,6 +88,8 @@ case "$command" in
81
88
  trap 'rm -f "$choices"' EXIT
82
89
  node "$FACTORY_ROOT/src/setup-menu.js" "$choices"
83
90
  provider=$(jq -r .provider "$choices")
91
+ factory_name=$(jq -r .factoryName "$choices")
92
+ factory_purpose=$(jq -r .factoryPurpose "$choices")
84
93
  location=$(jq -r .location "$choices")
85
94
  enterprise_org=$(jq -r .githubOrg "$choices")
86
95
  aws_region=$(jq -r .awsRegion "$choices")
@@ -96,6 +105,7 @@ case "$command" in
96
105
  deployment=$(az deployment group create --name agent-factory-setup --resource-group "$RESOURCE_GROUP" --template-file "$FACTORY_ROOT/infra/main.bicep" --parameters adminSshKey="$(< "$key.pub")" operatorObjectId="$operator" --output json)
97
106
  vault=$(jq -r '.properties.outputs.keyVaultName.value' <<<"$deployment")
98
107
  namespace=$(jq -r '.properties.outputs.serviceBusNamespace.value' <<<"$deployment")
108
+ storage=$(jq -r '.properties.outputs.storageAccount.value' <<<"$deployment")
99
109
  VAULT=$vault
100
110
  NAMESPACE=$namespace
101
111
  mkdir -p "$(dirname "$CONFIG_FILE")"
@@ -104,6 +114,9 @@ case "$command" in
104
114
  printf 'FACTORY_VM=%q\n' agent-factory-vm
105
115
  printf 'FACTORY_SERVICE_BUS=%q\n' "$namespace"
106
116
  printf 'FACTORY_KEY_VAULT=%q\n' "$vault"
117
+ printf 'FACTORY_STORAGE_ACCOUNT=%q\n' "$storage"
118
+ printf 'FACTORY_NAME=%q\n' "$factory_name"
119
+ printf 'FACTORY_PURPOSE=%q\n' "$factory_purpose"
107
120
  } > "$CONFIG_FILE"
108
121
  chmod 0600 "$CONFIG_FILE"
109
122
  ip=$(curl -fsS https://api.ipify.org)
@@ -151,7 +164,7 @@ case "$command" in
151
164
  printf 'Azure foundation and credential vault ready. Config: %s\n' "$CONFIG_FILE"
152
165
  if [[ $deploy_now == true ]]; then
153
166
  source_ref=$(gh api repos/itsvedantkumar/factory-ai/commits/main --jq .sha)
154
- parameters=(KEY_VAULT_NAME "$vault" SERVICE_BUS_NAMESPACE "$namespace" SERVICE_BUS_QUEUE code-tasks SOURCE_REPOSITORY itsvedantkumar/factory-ai SOURCE_REF "$source_ref")
167
+ parameters=(KEY_VAULT_NAME "$vault" SERVICE_BUS_NAMESPACE "$namespace" SERVICE_BUS_QUEUE code-tasks FACTORY_STORAGE_ACCOUNT "$storage" FACTORY_NAME "$factory_name" FACTORY_PURPOSE "$factory_purpose" SOURCE_REPOSITORY itsvedantkumar/factory-ai SOURCE_REF "$source_ref")
155
168
  if [[ $provider == bedrock ]]; then
156
169
  parameters+=(AWS_REGION "$aws_region")
157
170
  for role in SCOUT PLANNER BUILDER TESTER DEBUGGER REVIEWER SECURITY RELEASE; do parameters+=("FACTORY_MODEL_$role" "bedrock/$bedrock_builder"); done
@@ -7,6 +7,7 @@ StartLimitBurst=10
7
7
 
8
8
  [Service]
9
9
  Type=simple
10
+ SyslogIdentifier=factory-ai-control
10
11
  User=factory
11
12
  Group=factory
12
13
  WorkingDirectory=/opt/agent-factory/app
@@ -5,6 +5,7 @@ Wants=network-online.target
5
5
 
6
6
  [Service]
7
7
  Type=simple
8
+ SyslogIdentifier=factory-ai-release
8
9
  User=factory
9
10
  Group=factory
10
11
  WorkingDirectory=/opt/agent-factory/app
@@ -4,6 +4,7 @@ After=agent-factory-worker.service
4
4
 
5
5
  [Service]
6
6
  Type=oneshot
7
+ SyslogIdentifier=factory-ai-reporter
7
8
  User=factory
8
9
  Group=factory
9
10
  WorkingDirectory=/opt/agent-factory/app
@@ -5,6 +5,7 @@ Wants=network-online.target
5
5
 
6
6
  [Service]
7
7
  Type=simple
8
+ SyslogIdentifier=factory-ai-telegram
8
9
  User=factory
9
10
  Group=factory
10
11
  WorkingDirectory=/opt/agent-factory/app
@@ -1,13 +1,14 @@
1
1
  [Unit]
2
2
  Description=Factory AI durable worker
3
- After=network-online.target docker.service
4
- Wants=network-online.target
3
+ After=network-online.target docker.service factory-ai-qdrant.service factory-ai-ollama.service
4
+ Wants=network-online.target factory-ai-qdrant.service factory-ai-ollama.service
5
5
  Requires=docker.service
6
6
  StartLimitIntervalSec=300
7
7
  StartLimitBurst=10
8
8
 
9
9
  [Service]
10
10
  Type=simple
11
+ SyslogIdentifier=factory-ai-worker
11
12
  User=factory
12
13
  Group=factory
13
14
  WorkingDirectory=/opt/agent-factory/app
@@ -20,7 +20,7 @@ while (($# > 0)); do
20
20
  shift
21
21
  fi
22
22
  case "$name" in
23
- KEY_VAULT_NAME|SERVICE_BUS_NAMESPACE|SERVICE_BUS_QUEUE|SOURCE_REPOSITORY|SOURCE_REF|AWS_REGION|FACTORY_MODEL_SCOUT|FACTORY_MODEL_PLANNER|FACTORY_MODEL_BUILDER|FACTORY_MODEL_TESTER|FACTORY_MODEL_DEBUGGER|FACTORY_MODEL_REVIEWER|FACTORY_MODEL_SECURITY|FACTORY_MODEL_RELEASE) printf -v "$name" '%s' "$value" ;;
23
+ KEY_VAULT_NAME|SERVICE_BUS_NAMESPACE|SERVICE_BUS_QUEUE|SOURCE_REPOSITORY|SOURCE_REF|FACTORY_STORAGE_ACCOUNT|FACTORY_NAME|FACTORY_PURPOSE|AWS_REGION|FACTORY_MODEL_SCOUT|FACTORY_MODEL_PLANNER|FACTORY_MODEL_BUILDER|FACTORY_MODEL_TESTER|FACTORY_MODEL_DEBUGGER|FACTORY_MODEL_REVIEWER|FACTORY_MODEL_SECURITY|FACTORY_MODEL_RELEASE) printf -v "$name" '%s' "$value" ;;
24
24
  *) echo "Unknown deployment parameter: $name" >&2; exit 1 ;;
25
25
  esac
26
26
  done
@@ -62,6 +62,9 @@ KEY_VAULT_NAME="$KEY_VAULT_NAME" \
62
62
  SERVICE_BUS_NAMESPACE="$SERVICE_BUS_NAMESPACE" \
63
63
  SERVICE_BUS_QUEUE="$SERVICE_BUS_QUEUE" \
64
64
  FACTORY_WORKER_IMAGE="agent-factory-worker:$SOURCE_REF" \
65
+ FACTORY_STORAGE_ACCOUNT="${FACTORY_STORAGE_ACCOUNT:-}" \
66
+ FACTORY_NAME="${FACTORY_NAME:-Factory AI}" \
67
+ FACTORY_PURPOSE="${FACTORY_PURPOSE:-Ship secure reviewed software continuously}" \
65
68
  AWS_REGION="${AWS_REGION:-}" \
66
69
  FACTORY_MODEL_SCOUT="${FACTORY_MODEL_SCOUT:-}" \
67
70
  FACTORY_MODEL_PLANNER="${FACTORY_MODEL_PLANNER:-}" \
@@ -0,0 +1,16 @@
1
+ [Unit]
2
+ Description=Factory AI local embedding inference
3
+ After=docker.service
4
+ Requires=docker.service
5
+
6
+ [Service]
7
+ Type=simple
8
+ ExecStartPre=-/usr/bin/docker rm -f factory-ai-ollama
9
+ ExecStart=/usr/bin/docker run --rm --name factory-ai-ollama --userns host --cap-drop ALL --security-opt no-new-privileges --pids-limit 1024 --memory 6g --cpus 4 --publish 127.0.0.1:11434:11434 --env OLLAMA_MODELS=/models --volume /opt/agent-factory/state/ollama:/models ollama/ollama@sha256:3d8a05e3432d50ea57594fabe971e46cc8fe963a0f9f8c40400bd56cd5388e47
10
+ ExecStop=/usr/bin/docker stop -t 20 factory-ai-ollama
11
+ Restart=always
12
+ RestartSec=5
13
+ SyslogIdentifier=factory-ai-ollama
14
+
15
+ [Install]
16
+ WantedBy=multi-user.target
@@ -0,0 +1,16 @@
1
+ [Unit]
2
+ Description=Factory AI local Qdrant retrieval store
3
+ After=docker.service
4
+ Requires=docker.service
5
+
6
+ [Service]
7
+ Type=simple
8
+ ExecStartPre=-/usr/bin/docker rm -f factory-ai-qdrant
9
+ ExecStart=/usr/bin/docker run --rm --name factory-ai-qdrant --userns host --read-only --cap-drop ALL --security-opt no-new-privileges --pids-limit 1024 --memory 2g --cpus 2 --publish 127.0.0.1:6333:6333 --volume /opt/agent-factory/state/qdrant:/qdrant/storage --volume /opt/agent-factory/state/qdrant-snapshots:/qdrant/snapshots qdrant/qdrant@sha256:31407c0e8e32eb771b71718f1a4772e2ad47a07557917b21ac96792f40eb8007
10
+ ExecStop=/usr/bin/docker stop -t 20 factory-ai-qdrant
11
+ Restart=always
12
+ RestartSec=5
13
+ SyslogIdentifier=factory-ai-qdrant
14
+
15
+ [Install]
16
+ WantedBy=multi-user.target
@@ -0,0 +1,18 @@
1
+ [Unit]
2
+ Description=Publish Factory AI operator dashboard snapshot
3
+ After=network-online.target agent-factory-control.service
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=oneshot
8
+ User=factory
9
+ Group=factory
10
+ WorkingDirectory=/opt/agent-factory/app
11
+ EnvironmentFile=/etc/agent-factory-control.env
12
+ ExecStart=/usr/bin/node /opt/agent-factory/app/src/snapshot.js
13
+ NoNewPrivileges=true
14
+ PrivateTmp=true
15
+ ProtectSystem=strict
16
+ ProtectHome=true
17
+ UMask=0027
18
+ SyslogIdentifier=factory-ai-snapshot
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=Refresh Factory AI operator dashboard every minute
3
+
4
+ [Timer]
5
+ OnBootSec=2m
6
+ OnUnitActiveSec=1m
7
+ RandomizedDelaySec=5
8
+ Persistent=true
9
+
10
+ [Install]
11
+ WantedBy=timers.target
@@ -6,6 +6,7 @@ Requires=docker.service
6
6
 
7
7
  [Service]
8
8
  Type=oneshot
9
+ SyslogIdentifier=factory-ai-updater
9
10
  User=root
10
11
  Group=root
11
12
  ExecStart=/opt/agent-factory/app/bootstrap/auto-update.sh
@@ -35,6 +35,7 @@ test "$(node --version | cut -d. -f1)" = "v20"
35
35
  id "$FACTORY_USER" >/dev/null 2>&1 || useradd --system --create-home --shell /usr/sbin/nologin "$FACTORY_USER"
36
36
  test -f "$APP_DIR/package-lock.json"
37
37
  npm ci --omit=dev --prefix "$APP_DIR"
38
+ factory_version=$(node -p "require('$APP_DIR/package.json').version")
38
39
  docker build --file "$APP_DIR/Dockerfile.worker" --tag "$FACTORY_WORKER_IMAGE" "$APP_DIR"
39
40
  for scanner_image in \
40
41
  aquasec/trivy@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f \
@@ -43,6 +44,8 @@ for scanner_image in \
43
44
  semgrep/semgrep@sha256:183a149fb3e9700ab5294a7b4ab0241a826fd046bc8b721062fbea80fdfa438f; do
44
45
  docker pull "$scanner_image"
45
46
  done
47
+ docker pull ollama/ollama@sha256:3d8a05e3432d50ea57594fabe971e46cc8fe963a0f9f8c40400bd56cd5388e47
48
+ docker pull qdrant/qdrant@sha256:31407c0e8e32eb771b71718f1a4772e2ad47a07557917b21ac96792f40eb8007
46
49
 
47
50
  STATE_DEVICE=/dev/disk/azure/scsi1/lun0
48
51
  if [[ -b $STATE_DEVICE ]]; then
@@ -64,7 +67,8 @@ for secret_name in "${GITHUB_TOKEN_SECRET:-github-token}"; do
64
67
  az keyvault secret show --vault-name "$KEY_VAULT_NAME" --name "$secret_name" --query id --output none
65
68
  done
66
69
 
67
- install -d -o "$FACTORY_USER" -g "$FACTORY_USER" -m 0750 /opt/agent-factory/state /opt/agent-factory/state/home /opt/agent-factory/state/memory /opt/agent-factory/state/telegram /opt/agent-factory/workspaces /opt/agent-factory/logs
70
+ install -d -o "$FACTORY_USER" -g "$FACTORY_USER" -m 0750 /opt/agent-factory/state /opt/agent-factory/state/home /opt/agent-factory/state/memory /opt/agent-factory/state/telegram /opt/agent-factory/state/retrieval /opt/agent-factory/workspaces /opt/agent-factory/logs
71
+ install -d -o root -g root -m 0750 /opt/agent-factory/state/qdrant /opt/agent-factory/state/qdrant-snapshots /opt/agent-factory/state/ollama
68
72
  install -m 0600 -o root -g root /dev/null /etc/agent-factory.env
69
73
  {
70
74
  printf 'SERVICE_BUS_NAMESPACE=%s\n' "$SERVICE_BUS_NAMESPACE"
@@ -74,13 +78,20 @@ install -m 0600 -o root -g root /dev/null /etc/agent-factory.env
74
78
  printf 'KEY_VAULT_NAME=%s\n' "$KEY_VAULT_NAME"
75
79
  printf 'AZURE_SUBSCRIPTION_ID=%s\n' "$subscription_id"
76
80
  printf 'FACTORY_RESOURCE_GROUP=%s\n' "$resource_group"
81
+ [[ -n ${FACTORY_STORAGE_ACCOUNT:-} ]] && printf 'FACTORY_STORAGE_ACCOUNT=%s\n' "$FACTORY_STORAGE_ACCOUNT"
77
82
  printf 'FACTORY_STATE_DIR=/opt/agent-factory/state\n'
78
83
  printf 'FACTORY_WORKSPACE_DIR=/opt/agent-factory/workspaces\n'
79
84
  printf 'FACTORY_REGISTRY=%s/config/capabilities.json\n' "$APP_DIR"
80
85
  printf 'FACTORY_WORKER_IMAGE=%s\n' "$FACTORY_WORKER_IMAGE"
86
+ printf 'FACTORY_VERSION=%s\n' "$factory_version"
87
+ printf 'FACTORY_NAME=%s\n' "${FACTORY_NAME:-Factory AI}"
88
+ printf 'FACTORY_PURPOSE=%s\n' "${FACTORY_PURPOSE:-Ship secure reviewed software continuously}"
81
89
  printf 'MAX_CONCURRENCY=3\n'
82
90
  printf 'TASK_TIMEOUT_MS=1800000\n'
83
91
  printf 'MAX_DELIVERY_COUNT=8\n'
92
+ printf 'FACTORY_VERSION=%s\n' "$factory_version"
93
+ printf 'FACTORY_NAME=%s\n' "${FACTORY_NAME:-Factory AI}"
94
+ printf 'FACTORY_PURPOSE=%s\n' "${FACTORY_PURPOSE:-Ship secure reviewed software continuously}"
84
95
  [[ -n ${AWS_REGION:-} ]] && printf 'AWS_REGION=%s\nAWS_DEFAULT_REGION=%s\n' "$AWS_REGION" "$AWS_REGION"
85
96
  for variable in FACTORY_MODEL_SCOUT FACTORY_MODEL_PLANNER FACTORY_MODEL_BUILDER FACTORY_MODEL_TESTER FACTORY_MODEL_DEBUGGER FACTORY_MODEL_REVIEWER FACTORY_MODEL_SECURITY FACTORY_MODEL_RELEASE; do
86
97
  [[ -n ${!variable:-} ]] && printf '%s=%s\n' "$variable" "${!variable}"
@@ -96,6 +107,7 @@ install -m 0600 -o root -g root /dev/null /etc/agent-factory-control.env
96
107
  printf 'KEY_VAULT_NAME=%s\n' "$KEY_VAULT_NAME"
97
108
  printf 'AZURE_SUBSCRIPTION_ID=%s\n' "$subscription_id"
98
109
  printf 'FACTORY_RESOURCE_GROUP=%s\n' "$resource_group"
110
+ [[ -n ${FACTORY_STORAGE_ACCOUNT:-} ]] && printf 'FACTORY_STORAGE_ACCOUNT=%s\n' "$FACTORY_STORAGE_ACCOUNT"
99
111
  printf 'FACTORY_STATE_DIR=/opt/agent-factory/state\n'
100
112
  printf 'FACTORY_REGISTRY=%s/config/capabilities.json\n' "$APP_DIR"
101
113
  printf 'MAX_DELIVERY_COUNT=8\n'
@@ -106,6 +118,8 @@ install -m 0600 -o root -g root /dev/null /etc/agent-factory-control.env
106
118
  } > /etc/agent-factory-control.env
107
119
 
108
120
  chown -R "$FACTORY_USER:$FACTORY_USER" /opt/agent-factory/state /opt/agent-factory/workspaces /opt/agent-factory/logs
121
+ chown root:root /opt/agent-factory/state/qdrant /opt/agent-factory/state/qdrant-snapshots /opt/agent-factory/state/ollama
122
+ chmod 0750 /opt/agent-factory/state/qdrant /opt/agent-factory/state/qdrant-snapshots /opt/agent-factory/state/ollama
109
123
  chown -R root:root "$APP_DIR"
110
124
  chmod -R go-w "$APP_DIR"
111
125
  install -m 0644 "$APP_DIR/bootstrap/agent-factory-worker.service" /etc/systemd/system/agent-factory-worker.service
@@ -117,6 +131,10 @@ install -m 0644 "$APP_DIR/bootstrap/agent-factory-telegram.service" /etc/systemd
117
131
  chmod 0755 "$APP_DIR/bootstrap/auto-update.sh"
118
132
  install -m 0644 "$APP_DIR/bootstrap/factory-ai-update.service" /etc/systemd/system/factory-ai-update.service
119
133
  install -m 0644 "$APP_DIR/bootstrap/factory-ai-update.timer" /etc/systemd/system/factory-ai-update.timer
134
+ install -m 0644 "$APP_DIR/bootstrap/factory-ai-snapshot.service" /etc/systemd/system/factory-ai-snapshot.service
135
+ install -m 0644 "$APP_DIR/bootstrap/factory-ai-snapshot.timer" /etc/systemd/system/factory-ai-snapshot.timer
136
+ install -m 0644 "$APP_DIR/bootstrap/factory-ai-qdrant.service" /etc/systemd/system/factory-ai-qdrant.service
137
+ install -m 0644 "$APP_DIR/bootstrap/factory-ai-ollama.service" /etc/systemd/system/factory-ai-ollama.service
120
138
  systemctl daemon-reload
121
139
  systemctl enable --now agent-factory-worker.service
122
140
  systemctl enable --now agent-factory-control.service
@@ -124,5 +142,9 @@ systemctl enable --now agent-factory-release.service
124
142
  systemctl enable --now agent-factory-reporter.timer
125
143
  systemctl enable --now agent-factory-telegram.service
126
144
  systemctl enable --now factory-ai-update.timer
145
+ systemctl enable --now factory-ai-snapshot.timer
146
+ systemctl enable --now factory-ai-qdrant.service factory-ai-ollama.service
147
+ for _ in $(seq 1 60); do curl -fsS http://127.0.0.1:11434/api/tags >/dev/null && break; sleep 2; done
148
+ docker exec factory-ai-ollama ollama show embeddinggemma >/dev/null 2>&1 || docker exec factory-ai-ollama ollama pull embeddinggemma
127
149
  systemctl restart agent-factory-control.service agent-factory-worker.service agent-factory-release.service
128
150
  echo "Agent factory worker installed"
package/infra/main.bicep CHANGED
@@ -20,6 +20,7 @@ var prefix = 'agent-factory'
20
20
  var unique = uniqueString(subscription().subscriptionId, resourceGroup().id)
21
21
  var keyVaultName = 'af${take(unique, 18)}'
22
22
  var serviceBusName = 'af-${unique}'
23
+ var storageName = 'fa${take(unique, 20)}'
23
24
 
24
25
  resource nsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = {
25
26
  name: '${prefix}-nsg'
@@ -147,6 +148,43 @@ resource vault 'Microsoft.KeyVault/vaults@2024-11-01' = {
147
148
  }
148
149
  }
149
150
 
151
+ resource storage 'Microsoft.Storage/storageAccounts@2025-01-01' = {
152
+ name: storageName
153
+ location: location
154
+ sku: {
155
+ name: 'Standard_ZRS'
156
+ }
157
+ kind: 'StorageV2'
158
+ properties: {
159
+ allowBlobPublicAccess: false
160
+ allowSharedKeyAccess: false
161
+ defaultToOAuthAuthentication: true
162
+ minimumTlsVersion: 'TLS1_2'
163
+ publicNetworkAccess: 'Enabled'
164
+ supportsHttpsTrafficOnly: true
165
+ }
166
+ }
167
+
168
+ resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2025-01-01' = {
169
+ parent: storage
170
+ name: 'default'
171
+ properties: {
172
+ deleteRetentionPolicy: {
173
+ enabled: true
174
+ days: 30
175
+ }
176
+ isVersioningEnabled: true
177
+ }
178
+ }
179
+
180
+ resource operatorContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-01-01' = {
181
+ parent: blobService
182
+ name: 'operator'
183
+ properties: {
184
+ publicAccess: 'None'
185
+ }
186
+ }
187
+
150
188
  resource serviceBus 'Microsoft.ServiceBus/namespaces@2024-01-01' = {
151
189
  name: serviceBusName
152
190
  location: location
@@ -299,11 +337,32 @@ resource workerCostReader 'Microsoft.Authorization/roleAssignments@2022-04-01' =
299
337
  }
300
338
  }
301
339
 
340
+ resource workerBlobContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
341
+ name: guid(storage.id, vm.id, 'storage-blob-data-contributor')
342
+ scope: storage
343
+ properties: {
344
+ principalId: vm.identity.principalId
345
+ principalType: 'ServicePrincipal'
346
+ roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')
347
+ }
348
+ }
349
+
350
+ resource operatorBlobReader 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
351
+ name: guid(storage.id, operatorObjectId, 'storage-blob-data-reader')
352
+ scope: storage
353
+ properties: {
354
+ principalId: operatorObjectId
355
+ principalType: 'User'
356
+ roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1')
357
+ }
358
+ }
359
+
302
360
  output vmName string = vm.name
303
361
  output privateIp string = nic.properties.ipConfigurations[0].properties.privateIPAddress
304
362
  output egressIp string = egressIp.properties.ipAddress
305
363
  output keyVaultName string = vault.name
306
364
  output serviceBusNamespace string = serviceBus.name
365
+ output storageAccount string = storage.name
307
366
  output controlQueue string = queues[0].name
308
367
  output agentQueue string = queues[1].name
309
368
  output releaseQueue string = queues[2].name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "factory-ai",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Deploy a private autonomous coding-agent factory on Azure: isolated builders, testers, security reviewers, durable orchestration, multi-model routing, memory, cost controls, and gated GitHub pull requests.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -40,6 +40,7 @@
40
40
  "agent-factory-release": "src/release-service.js",
41
41
  "agent-factory-task": "src/task-entry.js",
42
42
  "factory-ai-telegram": "src/telegram-service.js",
43
+ "factory-ai-snapshot": "src/snapshot.js",
43
44
  "agent-factory-worker": "src/worker.js"
44
45
  },
45
46
  "files": [
@@ -63,6 +64,7 @@
63
64
  "@azure/identity": "4.13.1",
64
65
  "@azure/keyvault-secrets": "4.10.0",
65
66
  "@azure/service-bus": "7.9.5",
67
+ "@azure/storage-blob": "12.28.0",
66
68
  "@inquirer/prompts": "8.5.2",
67
69
  "@modelcontextprotocol/sdk": "1.29.0",
68
70
  "@modelcontextprotocol/server-memory": "2026.7.4",
@@ -1,10 +1,11 @@
1
1
  import { parseTaskResult } from "./validation.js";
2
2
 
3
3
  export class AgentExecutor {
4
- constructor({ workspaces, agentRunner, scannerSuite, sendControl }) {
4
+ constructor({ workspaces, agentRunner, scannerSuite, retriever, sendControl }) {
5
5
  this.workspaces = workspaces;
6
6
  this.agentRunner = agentRunner;
7
7
  this.scannerSuite = scannerSuite;
8
+ this.retriever = retriever;
8
9
  this.sendControl = sendControl;
9
10
  }
10
11
 
@@ -16,7 +17,10 @@ export class AgentExecutor {
16
17
 
17
18
  async processPlanning(message) {
18
19
  const directory = await this.workspaces.ensureObjective(message.objective);
19
- const delivery = await this.agentRunner.plan(message.objective, directory, message.context ?? []);
20
+ let semanticContext = "";
21
+ try { semanticContext = this.retriever ? await this.retriever.context(directory, message.objective.repository, message.objective.objective) : ""; } catch {}
22
+ const context = [...(message.context ?? []), ...(semanticContext ? [{ type: "local-semantic-retrieval", content: semanticContext }] : [])];
23
+ const delivery = await this.agentRunner.plan(message.objective, directory, context);
20
24
  await this.sendControl({ type: "planning_result", objectiveId: message.objectiveId, delivery });
21
25
  }
22
26
 
@@ -29,9 +33,15 @@ export class AgentExecutor {
29
33
  const scannerEvidence = message.task.role === "security" && this.scannerSuite
30
34
  ? await this.scannerSuite.scan(directory)
31
35
  : [];
36
+ let semanticContext = "";
37
+ if (message.task.role !== "release" && this.retriever) {
38
+ const query = `${message.objective.objective}\n${message.task.title}\n${message.task.instructions}`;
39
+ try { semanticContext = await this.retriever.context(directory, message.objective.repository, query); } catch {}
40
+ }
32
41
  const prompt = [
33
42
  "Execute only this assigned task, verify it, and report factual outcomes.",
34
43
  ...(scannerEvidence.length ? [`TRUSTED SCANNER EVIDENCE (mechanical output; do not claim checks beyond this evidence):\n${JSON.stringify(scannerEvidence)}`] : []),
44
+ ...(semanticContext ? [`LOCAL SEMANTIC CONTEXT (retrieved from this exact repository revision):\n${semanticContext}`] : []),
35
45
  ].join("\n\n");
36
46
  const result = parseTaskResult(await this.agentRunner.invoke({
37
47
  objective: message.objective,
@@ -47,11 +47,13 @@ export class AzureAgentRunner {
47
47
  }
48
48
 
49
49
  async promptForTask(objective, task, prompt, capabilities) {
50
+ const factoryName = this.environment.FACTORY_NAME ?? "Factory AI";
51
+ const factoryPurpose = this.environment.FACTORY_PURPOSE ?? "Ship secure reviewed software continuously";
50
52
  const skills = await Promise.all(capabilities.filter((item) => item.type === "skill").map(async (item) => (
51
53
  `ALLOWLISTED SKILL ${item.name}@${item.version}:\n${await readFile(item.path, "utf8")}`
52
54
  )));
53
55
  return [
54
- `You are a Factory AI isolated ${task.role} subagent.`,
56
+ `You are a ${factoryName} isolated ${task.role} subagent. Factory purpose: ${factoryPurpose}.`,
55
57
  "Work only in the assigned repository. Never inspect credentials, push Git refs, deploy, or install global tools.",
56
58
  "Use tools for evidence. Make the smallest correct change and verify every completion claim.",
57
59
  'Return only JSON: {"summary":"concise outcome","checks":["command/result"],"risks":["remaining risk"],"approval":"approved|changes_requested|not_applicable"}.',
@@ -115,7 +117,7 @@ export class AzureAgentRunner {
115
117
  ...Object.entries(this.registry.skills ?? {}),
116
118
  ...Object.entries(this.registry.mcp ?? {}),
117
119
  ].map(([name, item]) => [name, { version: item.version, roles: item.roles }]));
118
- const prompt = `You are a Factory AI planner subagent. Decompose objectives into the smallest executable DAG.
120
+ const prompt = `You are the ${this.environment.FACTORY_NAME ?? "Factory AI"} planner subagent. Factory purpose: ${this.environment.FACTORY_PURPOSE ?? "Ship secure reviewed software continuously"}. Decompose objectives into the smallest executable DAG.
119
121
  Allowed roles: scout, builder, tester, debugger, reviewer, security, release.
120
122
  Allowed capabilities: ${JSON.stringify(registrySummary)}
121
123
  Include tester, reviewer, and security ancestors of exactly one terminal release task.
@@ -31,6 +31,7 @@ export class AzureResponsesHarness {
31
31
  retries = 4,
32
32
  timeoutMs = 180_000,
33
33
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
34
+ userAgent = `factory-ai/${process.env.FACTORY_VERSION ?? "dev"}`,
34
35
  }) {
35
36
  this.endpoint = `${baseUrl.replace(/\/$/, "")}/responses`;
36
37
  this.apiKey = apiKey;
@@ -42,6 +43,7 @@ export class AzureResponsesHarness {
42
43
  this.retries = retries;
43
44
  this.timeoutMs = timeoutMs;
44
45
  this.sleep = sleep;
46
+ this.userAgent = userAgent;
45
47
  }
46
48
 
47
49
  async request(body) {
@@ -56,6 +58,8 @@ export class AzureResponsesHarness {
56
58
  "content-type": "application/json",
57
59
  "api-key": this.apiKey,
58
60
  authorization: `Bearer ${this.apiKey}`,
61
+ "user-agent": this.userAgent,
62
+ "x-ms-useragent": this.userAgent,
59
63
  },
60
64
  body: JSON.stringify(body),
61
65
  signal: controller.signal,
@@ -12,7 +12,7 @@ function definitions(tools) {
12
12
 
13
13
  export class BedrockHarness {
14
14
  constructor({ client, region = process.env.AWS_REGION ?? "us-east-1", model, tools, maxSteps = 40, maxOutputTokens = 4096 }) {
15
- this.client = client ?? new BedrockRuntimeClient({ region });
15
+ this.client = client ?? new BedrockRuntimeClient({ region, customUserAgent: `factory-ai/${process.env.FACTORY_VERSION ?? "dev"}` });
16
16
  this.model = model;
17
17
  this.tools = tools;
18
18
  this.maxSteps = maxSteps;
package/src/ceo.js CHANGED
@@ -6,6 +6,8 @@ import { loadConfig } from "./config.js";
6
6
  import { createBus, sendMessage } from "./bus.js";
7
7
  import { parseObjective } from "./validation.js";
8
8
 
9
+ process.title = "factory-ai-ceo";
10
+
9
11
  function argumentsFrom(argv) {
10
12
  const options = { baseBranch: "main", wait: false, timeoutMs: 3_600_000, words: [] };
11
13
  for (let index = 0; index < argv.length; index += 1) {
package/src/config.js CHANGED
@@ -9,6 +9,9 @@ const environmentSchema = z.object({
9
9
  KEY_VAULT_NAME: z.string().min(3),
10
10
  AZURE_SUBSCRIPTION_ID: z.string().optional(),
11
11
  FACTORY_RESOURCE_GROUP: z.string().default("factory-ai-rg"),
12
+ FACTORY_STORAGE_ACCOUNT: z.string().optional(),
13
+ FACTORY_NAME: z.string().min(1).max(80).default("Factory AI"),
14
+ FACTORY_PURPOSE: z.string().min(1).max(500).default("Ship secure reviewed software continuously"),
12
15
  FACTORY_STATE_DIR: z.string().default("/opt/agent-factory/state"),
13
16
  FACTORY_WORKSPACE_DIR: z.string().default("/opt/agent-factory/workspaces"),
14
17
  FACTORY_REGISTRY: z.string().default("/opt/agent-factory/app/config/capabilities.json"),
@@ -41,6 +44,9 @@ export function loadConfig(environment = process.env) {
41
44
  keyVaultUrl: `https://${env.KEY_VAULT_NAME}.vault.azure.net`,
42
45
  subscriptionId: env.AZURE_SUBSCRIPTION_ID,
43
46
  resourceGroup: env.FACTORY_RESOURCE_GROUP,
47
+ storageAccount: env.FACTORY_STORAGE_ACCOUNT,
48
+ factoryName: env.FACTORY_NAME,
49
+ factoryPurpose: env.FACTORY_PURPOSE,
44
50
  secretNames: {
45
51
  TEXTVED_AZURE_API_KEY: env.AZURE_PRIMARY_API_KEY_SECRET,
46
52
  TEXTVED_AZURE_BASE_URL: env.AZURE_PRIMARY_BASE_URL_SECRET,
@@ -19,6 +19,7 @@ const AZURE_ENVIRONMENT = [
19
19
  "FACTORY_MODEL_REVIEWER",
20
20
  "FACTORY_MODEL_SECURITY",
21
21
  "FACTORY_MODEL_RELEASE",
22
+ "FACTORY_VERSION",
22
23
  ];
23
24
 
24
25
  function parseOutput(stdout) {
@@ -37,7 +38,7 @@ export class ContainerAgentRunner {
37
38
  }
38
39
 
39
40
  async executePacket(packet, directory) {
40
- const name = `agent-${packet.objective.id}-${packet.task?.id ?? "planner"}`.toLowerCase().replaceAll(/[^a-z0-9_.-]/g, "-").slice(0, 63);
41
+ const name = `factory-ai-${packet.objective.id}-${packet.task?.id ?? "planner"}`.toLowerCase().replaceAll(/[^a-z0-9_.-]/g, "-").slice(0, 63);
41
42
  const args = [
42
43
  "run", "-i", "--rm", "--name", name,
43
44
  "--read-only",
@@ -7,6 +7,7 @@ import { loadRegistry } from "./registry.js";
7
7
  import { log } from "./log.js";
8
8
  import { ProjectMemory } from "./project-memory.js";
9
9
 
10
+ process.title = "factory-ai-control";
10
11
  const config = loadConfig();
11
12
  const bus = createBus(config, config.controlQueue, config.agentQueue);
12
13
  const releaseSender = bus.client.createSender(config.releaseQueue);
package/src/operator.js CHANGED
@@ -2,6 +2,8 @@ import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { gunzipSync } from "node:zlib";
5
+ import { DefaultAzureCredential } from "@azure/identity";
6
+ import { BlobServiceClient } from "@azure/storage-blob";
5
7
  import { run } from "./process.js";
6
8
 
7
9
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -21,6 +23,9 @@ export function createOperator(environment = process.env) {
21
23
  const vm = environment.FACTORY_VM ?? "agent-factory-vm";
22
24
  const namespace = environment.FACTORY_SERVICE_BUS ?? "";
23
25
  const vault = environment.FACTORY_KEY_VAULT ?? "";
26
+ const storageAccount = environment.FACTORY_STORAGE_ACCOUNT ?? "";
27
+ const factoryName = environment.FACTORY_NAME ?? "Factory AI";
28
+ const factoryPurpose = environment.FACTORY_PURPOSE ?? "Ship secure reviewed software continuously";
24
29
  const remote = async (script) => {
25
30
  for (let attempt = 0; attempt < 6; attempt += 1) {
26
31
  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"])); }
@@ -41,6 +46,15 @@ export function createOperator(environment = process.env) {
41
46
  };
42
47
  return {
43
48
  dashboard: async () => {
49
+ if (storageAccount) {
50
+ try {
51
+ const service = new BlobServiceClient(`https://${storageAccount}.blob.core.windows.net`, new DefaultAzureCredential());
52
+ const value = await service.getContainerClient("operator").getBlockBlobClient("dashboard.json").downloadToBuffer();
53
+ return JSON.parse(value.toString("utf8"));
54
+ } catch (error) {
55
+ if (!["BlobNotFound", "ContainerNotFound"].includes(error.code)) throw error;
56
+ }
57
+ }
44
58
  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");
45
59
  return JSON.parse(gunzipSync(Buffer.from(encoded, "base64")).toString("utf8"));
46
60
  },
@@ -56,7 +70,7 @@ export function createOperator(environment = process.env) {
56
70
  return command(path.join(root, "bin/factory"), [action]);
57
71
  },
58
72
  capabilities: async () => JSON.parse(await readFile(path.join(root, "config/capabilities.json"), "utf8")),
59
- config: () => ({ resourceGroup, vm, namespace, vault, models: { scout: "GPT-5.4 nano", simpleBuilder: "Kimi K2.7-Code", builder: "GPT-5.5", tester: "GPT-5.4", critical: "GPT-5.6" } }),
73
+ 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" } }),
60
74
  listSecrets: async () => withVault(async () => JSON.parse(await command("az", ["keyvault", "secret", "list", "--vault-name", vault, "--query", "[].{name:name,updated:attributes.updated}", "--output", "json"]))),
61
75
  setSecret: async (name, value) => withVault(async () => {
62
76
  if (!/^[A-Za-z0-9-]{1,127}$/.test(name) || !value) throw new Error("Valid secret name and value are required");
@@ -7,6 +7,7 @@ import { ReleaseBot } from "./release-bot.js";
7
7
  import { GitHubRelease } from "./release.js";
8
8
  import { log } from "./log.js";
9
9
 
10
+ process.title = "factory-ai-release";
10
11
  const config = loadConfig();
11
12
  Object.assign(process.env, await loadRuntimeSecrets(config, undefined, ["GH_TOKEN"]));
12
13
  await run("gh", ["auth", "setup-git"], { timeoutMs: 60_000 });
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,
package/src/reporter.js CHANGED
@@ -4,11 +4,15 @@ import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { aggregateDashboard, loadAzureCost, loadLocalState, loadQueueMetrics, stableStringify } from "./dashboard.js";
6
6
  import { loadConfig } from "./config.js";
7
+ import { DefaultAzureCredential } from "@azure/identity";
8
+ import { BlobServiceClient } from "@azure/storage-blob";
9
+
10
+ process.title = "factory-ai-reporter";
7
11
 
8
12
  function markdown(dashboard) {
9
13
  const objectives = Object.entries(dashboard.summary.objectives).map(([state, count]) => `${state}=${count}`).join(", ") || "none";
10
14
  const cost = dashboard.cost ? `\nAzure month-to-date: ${dashboard.cost.currency} ${dashboard.cost.monthToDate.toFixed(2)} (billing data may be delayed)\n` : "";
11
- 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`;
12
16
  }
13
17
 
14
18
  async function atomicWrite(file, content) {
@@ -30,16 +34,31 @@ export async function writeHourlyReport(root, dashboard, { now = new Date(), ret
30
34
  return stem;
31
35
  }
32
36
 
37
+ export async function uploadDashboardSnapshot(config, dashboard, {
38
+ credential = new DefaultAzureCredential(),
39
+ createClient = (url, auth) => new BlobServiceClient(url, auth),
40
+ } = {}) {
41
+ if (!config.storageAccount) return false;
42
+ const service = createClient(`https://${config.storageAccount}.blob.core.windows.net`, credential);
43
+ const blob = service.getContainerClient("operator").getBlockBlobClient("dashboard.json");
44
+ await blob.uploadData(Buffer.from(stableStringify(dashboard)), {
45
+ blobHTTPHeaders: { blobContentType: "application/json; charset=utf-8", blobCacheControl: "no-store" },
46
+ });
47
+ return true;
48
+ }
49
+
33
50
  async function main() {
34
51
  const root = process.env.FACTORY_STATE_DIR ?? "/opt/agent-factory/state";
52
+ const config = loadConfig();
35
53
  const loaded = await loadLocalState(root);
36
- const queue = process.env.SERVICE_BUS_NAMESPACE ? await loadQueueMetrics(loadConfig()) : {};
54
+ const queue = process.env.SERVICE_BUS_NAMESPACE ? await loadQueueMetrics(config) : {};
37
55
  let cost = null;
38
56
  if (process.env.AZURE_SUBSCRIPTION_ID) {
39
- try { cost = await loadAzureCost(loadConfig()); } catch (error) { loaded.warnings.push(`Cost unavailable: ${error.message}`); }
57
+ try { cost = await loadAzureCost(config); } catch (error) { loaded.warnings.push(`Cost unavailable: ${error.message}`); }
40
58
  }
41
59
  const dashboard = aggregateDashboard({ ...loaded, queue, cost, runtime: { status: "running" } });
42
60
  const stem = await writeHourlyReport(root, dashboard);
61
+ await uploadDashboardSnapshot(config, dashboard);
43
62
  process.stdout.write(`${JSON.stringify({ event: "hourly_report", report: stem })}\n`);
44
63
  }
45
64
 
@@ -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
+
6
+ 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"]);
7
+ const ignoredDirectories = new Set([".git", ".next", ".turbo", "build", "coverage", "dist", "node_modules", "vendor"]);
8
+
9
+ export function chunkText(file, value, { linesPerChunk = 120, overlapLines = 20 } = {}) {
10
+ const lines = value.split("\n");
11
+ const chunks = [];
12
+ const step = Math.max(1, linesPerChunk - overlapLines);
13
+ for (let start = 0; start < lines.length; start += step) {
14
+ const content = lines.slice(start, start + linesPerChunk).join("\n").trim();
15
+ if (content) chunks.push({ path: file, startLine: start + 1, endLine: Math.min(lines.length, start + linesPerChunk), content });
16
+ if (start + linesPerChunk >= lines.length) break;
17
+ }
18
+ return chunks;
19
+ }
20
+
21
+ export function formatRetrievedContext(points, maxCharacters = 12_000) {
22
+ let output = "";
23
+ for (const point of points) {
24
+ const payload = point.payload ?? {};
25
+ const section = `\n--- ${payload.path}:${payload.startLine} score=${Number(point.score ?? 0).toFixed(3)} ---\n${payload.content ?? ""}\n`;
26
+ if (output.length + section.length > maxCharacters) break;
27
+ output += section;
28
+ }
29
+ return output.trim();
30
+ }
31
+
32
+ async function collectFiles(directory, root, output, limits) {
33
+ if (output.length >= limits.maxFiles) return;
34
+ for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
35
+ if (entry.isSymbolicLink?.() || ignoredDirectories.has(entry.name) || output.length >= limits.maxFiles) continue;
36
+ const absolute = path.join(directory, entry.name);
37
+ if (entry.isDirectory()) await collectFiles(absolute, root, output, limits);
38
+ else if (entry.isFile() && textExtensions.has(path.extname(entry.name).toLowerCase())) {
39
+ const metadata = await lstat(absolute);
40
+ if (metadata.size <= limits.maxFileBytes) output.push({ absolute, relative: path.relative(root, absolute) });
41
+ }
42
+ }
43
+ }
44
+
45
+ function pointId(value) {
46
+ const hash = createHash("sha256").update(value).digest("hex");
47
+ return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
48
+ }
49
+
50
+ export class LocalRetriever {
51
+ #locks = new Map();
52
+
53
+ constructor({
54
+ stateDir,
55
+ ollamaUrl = "http://127.0.0.1:11434",
56
+ qdrantUrl = "http://127.0.0.1:6333",
57
+ model = "embeddinggemma",
58
+ collection = "factory_ai_code",
59
+ fetch = globalThis.fetch,
60
+ }) {
61
+ this.stateDir = stateDir;
62
+ this.manifestFile = path.join(stateDir, "retrieval", "manifest.json");
63
+ this.ollamaUrl = ollamaUrl;
64
+ this.qdrantUrl = qdrantUrl;
65
+ this.model = model;
66
+ this.collection = collection;
67
+ this.fetch = fetch;
68
+ }
69
+
70
+ async request(url, options = {}) {
71
+ const response = await this.fetch(url, { ...options, signal: AbortSignal.timeout(120_000) });
72
+ if (!response.ok) throw new Error(`Local retrieval HTTP ${response.status}: ${url}`);
73
+ return response.status === 204 ? {} : response.json();
74
+ }
75
+
76
+ async embed(input) {
77
+ const result = await this.request(`${this.ollamaUrl}/api/embed`, {
78
+ method: "POST",
79
+ headers: { "content-type": "application/json" },
80
+ body: JSON.stringify({ model: this.model, input }),
81
+ });
82
+ return result.embeddings;
83
+ }
84
+
85
+ async loadManifest() {
86
+ try { return JSON.parse(await readFile(this.manifestFile, "utf8")); } catch (error) { if (error.code === "ENOENT") return {}; throw error; }
87
+ }
88
+
89
+ async saveManifest(value) {
90
+ await mkdir(path.dirname(this.manifestFile), { recursive: true, mode: 0o750 });
91
+ const temporary = `${this.manifestFile}.${process.pid}.tmp`;
92
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o640 });
93
+ await rename(temporary, this.manifestFile);
94
+ }
95
+
96
+ async ensureIndexed(directory, repository) {
97
+ const previous = this.#locks.get(repository) ?? Promise.resolve();
98
+ const current = previous.catch(() => {}).then(() => this.#index(directory, repository));
99
+ this.#locks.set(repository, current);
100
+ try { return await current; } finally { if (this.#locks.get(repository) === current) this.#locks.delete(repository); }
101
+ }
102
+
103
+ async #index(directory, repository) {
104
+ const commit = (await run("git", ["-C", directory, "rev-parse", "HEAD"])).stdout.trim();
105
+ const manifest = await this.loadManifest();
106
+ if (manifest[repository]?.commit === commit && manifest[repository]?.model === this.model) return false;
107
+ const files = [];
108
+ await collectFiles(directory, directory, files, { maxFiles: 2000, maxFileBytes: 256_000 });
109
+ const chunks = [];
110
+ for (const file of files) {
111
+ const value = await readFile(file.absolute, "utf8");
112
+ chunks.push(...chunkText(file.relative, value));
113
+ if (chunks.length >= 5000) break;
114
+ }
115
+ const selected = chunks.slice(0, 5000);
116
+ if (selected.length === 0) return false;
117
+ const sample = await this.embed([`${selected[0].path}\n${selected[0].content}`]);
118
+ const vectorSize = sample[0].length;
119
+ const collectionResponse = await this.fetch(`${this.qdrantUrl}/collections/${this.collection}`, { signal: AbortSignal.timeout(30_000) });
120
+ if (collectionResponse.status === 404) {
121
+ await this.request(`${this.qdrantUrl}/collections/${this.collection}`, {
122
+ method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ vectors: { size: vectorSize, distance: "Cosine" } }),
123
+ });
124
+ } else if (!collectionResponse.ok) throw new Error(`Qdrant collection HTTP ${collectionResponse.status}`);
125
+ await this.request(`${this.qdrantUrl}/collections/${this.collection}/points/delete?wait=true`, {
126
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ filter: { must: [{ key: "repository", match: { value: repository } }] } }),
127
+ });
128
+ for (let start = 0; start < selected.length; start += 32) {
129
+ const batch = selected.slice(start, start + 32);
130
+ const remaining = batch.slice(1);
131
+ const embeddings = start === 0
132
+ ? [sample[0], ...(remaining.length ? await this.embed(remaining.map((item) => `${item.path}\n${item.content}`)) : [])]
133
+ : await this.embed(batch.map((item) => `${item.path}\n${item.content}`));
134
+ const points = batch.map((item, index) => ({ id: pointId(`${repository}:${commit}:${item.path}:${item.startLine}`), vector: embeddings[index], payload: { repository, commit, ...item } }));
135
+ await this.request(`${this.qdrantUrl}/collections/${this.collection}/points?wait=true`, {
136
+ method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ points }),
137
+ });
138
+ }
139
+ manifest[repository] = { commit, model: this.model, chunks: selected.length, indexedAt: new Date().toISOString() };
140
+ await this.saveManifest(manifest);
141
+ return true;
142
+ }
143
+
144
+ async context(directory, repository, query, { limit = 8, maxCharacters = 12_000 } = {}) {
145
+ await this.ensureIndexed(directory, repository);
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 } }] }, limit, with_payload: true }),
151
+ });
152
+ return formatRetrievedContext(result.result?.points ?? [], maxCharacters);
153
+ }
154
+ }
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 });
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { aggregateDashboard, loadAzureCost, loadLocalState, loadQueueMetrics } from "./dashboard.js";
3
+ import { loadConfig } from "./config.js";
4
+ import { uploadDashboardSnapshot } from "./reporter.js";
5
+
6
+ process.title = "factory-ai-snapshot";
7
+ const config = loadConfig();
8
+ const root = process.env.FACTORY_STATE_DIR ?? "/opt/agent-factory/state";
9
+ const loaded = await loadLocalState(root);
10
+ const queue = await loadQueueMetrics(config);
11
+ let cost = null;
12
+ try { cost = await loadAzureCost(config); } catch (error) { loaded.warnings.push(`Cost unavailable: ${error.message}`); }
13
+ const dashboard = aggregateDashboard({ ...loaded, queue, cost, runtime: { status: "running" } });
14
+ await uploadDashboardSnapshot(config, dashboard);
15
+ process.stdout.write(`${JSON.stringify({ event: "dashboard_snapshot", generatedAt: dashboard.generatedAt })}\n`);
package/src/task-entry.js CHANGED
@@ -10,6 +10,7 @@ const input = await new Promise((resolve, reject) => {
10
10
  process.stdin.on("error", reject);
11
11
  });
12
12
  const packet = JSON.parse(input);
13
+ process.title = `factory-ai-${packet.task?.role ?? packet.mode}`.slice(0, 63);
13
14
  const registry = await loadRegistry("/opt/agent-factory/app/config/capabilities.json");
14
15
  const runner = new AzureAgentRunner({ timeoutMs: 1_800_000 }, registry);
15
16
  let result;
@@ -11,6 +11,7 @@ import { loadLocalState, loadQueueMetrics } from "./dashboard.js";
11
11
  import { formatObjectiveProgress, isAllowedChat, objectiveFromTelegram, parseTelegramCommand } from "./telegram.js";
12
12
  import { log } from "./log.js";
13
13
 
14
+ process.title = "factory-ai-telegram";
14
15
  const config = loadConfig();
15
16
  Object.assign(process.env, await loadRuntimeSecrets(config, undefined, ["TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_CHAT_IDS"]));
16
17
  const token = process.env.TELEGRAM_BOT_TOKEN;
@@ -69,7 +70,7 @@ async function statusText() {
69
70
  const [{ states }, queue] = await Promise.all([loadLocalState(config.stateDir), loadQueueMetrics(config)]);
70
71
  const counts = {};
71
72
  for (const state of states) counts[state.status ?? "unknown"] = (counts[state.status ?? "unknown"] ?? 0) + 1;
72
- return [`Factory AI`, `Queue: ${queue.active} active, ${queue.deadLetter} dead-letter`, `Objectives: ${Object.entries(counts).map(([name, count]) => `${name} ${count}`).join(", ") || "none"}`].join("\n");
73
+ return [config.factoryName, `Queue: ${queue.active} active, ${queue.deadLetter} dead-letter`, `Objectives: ${Object.entries(counts).map(([name, count]) => `${name} ${count}`).join(", ") || "none"}`].join("\n");
73
74
  }
74
75
 
75
76
  async function processUpdate(update) {
@@ -92,7 +93,7 @@ async function processUpdate(update) {
92
93
  }
93
94
  if (command.type === "objective") {
94
95
  const state = JSON.parse(await readFile(path.join(config.stateDir, command.objectiveId, "state.json"), "utf8"));
95
- return reply(message.chat.id, formatObjectiveProgress(state));
96
+ return reply(message.chat.id, formatObjectiveProgress(state, config.factoryName));
96
97
  }
97
98
  const objective = objectiveFromTelegram(update.update_id, command);
98
99
  await sendMessage(sender, objective, objective.id);
@@ -110,7 +111,7 @@ async function notifyProgress() {
110
111
  let state;
111
112
  try { state = JSON.parse(await readFile(path.join(config.stateDir, objectiveId, "state.json"), "utf8")); }
112
113
  catch (error) { if (error.code === "ENOENT") continue; throw error; }
113
- const text = formatObjectiveProgress(state);
114
+ const text = formatObjectiveProgress(state, config.factoryName);
114
115
  const digest = createHash("sha256").update(text).digest("hex");
115
116
  if (digest === subscription.lastDigest) continue;
116
117
  await reply(subscription.chatId, text);
package/src/telegram.js CHANGED
@@ -38,12 +38,12 @@ export function objectiveFromTelegram(updateId, command, now = new Date()) {
38
38
  };
39
39
  }
40
40
 
41
- export function formatObjectiveProgress(state) {
41
+ export function formatObjectiveProgress(state, factoryName = "Factory AI") {
42
42
  const tasks = state.tasks ?? [];
43
43
  const results = state.results ?? {};
44
44
  const complete = tasks.filter((task) => results[task.id]?.status === "succeeded").length;
45
45
  const lines = [
46
- `Factory AI objective ${state.objective?.id ?? "unknown"}`,
46
+ `${factoryName} objective ${state.objective?.id ?? "unknown"}`,
47
47
  state.objective?.objective ?? "",
48
48
  `Status: ${state.status ?? "unknown"}`,
49
49
  `${complete}/${tasks.length} tasks complete`,
package/src/tui.js CHANGED
@@ -5,10 +5,12 @@ import { createOperator } from "./operator.js";
5
5
  if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Factory AI UI requires an interactive terminal");
6
6
 
7
7
  const operator = createOperator();
8
- const screen = blessed.screen({ smartCSR: true, title: "Factory AI", fullUnicode: true, dockBorders: true });
8
+ const factoryName = process.env.FACTORY_NAME ?? "Factory AI";
9
+ const factoryPurpose = process.env.FACTORY_PURPOSE ?? "Ship secure reviewed software continuously";
10
+ const screen = blessed.screen({ smartCSR: true, title: factoryName, fullUnicode: true, dockBorders: true });
9
11
  const colors = { bg: "#0d0f12", panel: "#15191f", border: "#303743", text: "#d9e0e8", muted: "#7f8b99", accent: "#78dba9", warn: "#efc46b", danger: "#ef7d7d", blue: "#77a8ff" };
10
12
 
11
- blessed.box({ parent: screen, top: 0, left: 0, width: "100%", height: 3, tags: true, style: { bg: colors.panel, fg: colors.text }, content: " {bold}{#78dba9-fg}FACTORY AI{/} {/bold} private coding organization" });
13
+ blessed.box({ parent: screen, top: 0, left: 0, width: "100%", height: 3, tags: true, style: { bg: colors.panel, fg: colors.text }, content: ` {bold}{#78dba9-fg}${factoryName.toUpperCase()}{/} {/bold} ${factoryPurpose}` });
12
14
  const menu = blessed.list({ parent: screen, top: 3, left: 0, width: 23, bottom: 2, border: { type: "line" }, label: " Navigate ", keys: true, mouse: true, vi: true, items: ["Overview", "Objectives", "Agents", "Secrets", "Capabilities", "Logs", "Settings"], style: { bg: colors.panel, fg: colors.text, border: { fg: colors.border }, selected: { bg: colors.accent, fg: "#07130d", bold: true }, item: { fg: colors.text } } });
13
15
  const main = blessed.box({ parent: screen, top: 3, left: 23, right: 0, bottom: 2, border: { type: "line" }, label: " Overview ", tags: true, scrollable: true, alwaysScroll: true, keys: true, mouse: true, vi: true, scrollbar: { ch: "▐", style: { fg: colors.accent } }, padding: { left: 2, right: 2 }, style: { bg: colors.bg, fg: colors.text, border: { fg: colors.border } } });
14
16
  const footer = blessed.box({ parent: screen, bottom: 0, left: 0, width: "100%", height: 2, tags: true, style: { bg: colors.panel, fg: colors.muted }, content: " {bold}n{/} new objective {bold}r{/} refresh {bold}a{/} add secret {bold}p/u{/} pause/resume {bold}q{/} quit" });
@@ -65,7 +67,7 @@ function renderCapabilities() {
65
67
 
66
68
  function renderSettings() {
67
69
  const config = operator.config();
68
- main.setContent(`{bold}Runtime{/}\n\n Resource group ${config.resourceGroup}\n VM ${config.vm}\n Service Bus ${config.namespace}\n Key Vault ${config.vault}\n\n{bold}Model policy{/}\n\n Scout GPT-5.4 nano\n Simple builder Kimi K2.7-Code\n Builder GPT-5.5\n Tester GPT-5.4\n Critical roles GPT-5.6\n\n{#7f8b99-fg}Run factory setup to change providers or role routes.{/}`);
70
+ main.setContent(`{bold}Runtime{/}\n\n Resource group ${config.resourceGroup}\n VM ${config.vm}\n Service Bus ${config.namespace}\n Key Vault ${config.vault}\n Operator state ${config.storageAccount || "Run Command fallback"}\n\n{bold}Model policy{/}\n\n Scout GPT-5.4 nano\n Simple builder Kimi K2.7-Code\n Builder GPT-5.5\n Tester GPT-5.4\n Critical roles GPT-5.6\n\n{#7f8b99-fg}Run factory setup to change providers or role routes.{/}`);
69
71
  }
70
72
 
71
73
  function render() {
package/src/updater.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { pathToFileURL } from "node:url";
4
4
 
5
+ process.title = "factory-ai-updater";
6
+
5
7
  function parts(value) {
6
8
  if (!/^\d+\.\d+\.\d+$/.test(value)) throw new Error(`Unsupported version: ${value}`);
7
9
  return value.split(".").map(Number);
package/src/worker.js CHANGED
@@ -9,7 +9,9 @@ import { AgentExecutor } from "./agent-executor.js";
9
9
  import { sendMessage } from "./bus.js";
10
10
  import { ContainerAgentRunner } from "./container-runner.js";
11
11
  import { ScannerSuite } from "./scanner-suite.js";
12
+ import { LocalRetriever } from "./retriever.js";
12
13
 
14
+ process.title = "factory-ai-worker";
13
15
  const config = loadConfig();
14
16
  Object.assign(process.env, await loadRuntimeSecrets(config));
15
17
  await run("gh", ["auth", "setup-git"], { timeoutMs: 60_000 });
@@ -19,6 +21,7 @@ const executor = new AgentExecutor({
19
21
  workspaces: new WorkspaceManager(config.workspaceDir, config.timeoutMs),
20
22
  agentRunner: new ContainerAgentRunner({ image: config.workerImage, memoryDir: config.memoryDir, timeoutMs: config.timeoutMs }),
21
23
  scannerSuite: new ScannerSuite(),
24
+ retriever: new LocalRetriever({ stateDir: config.stateDir }),
22
25
  sendControl,
23
26
  });
24
27