iranti 0.1.4 → 0.2.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 (66) hide show
  1. package/README.md +174 -7
  2. package/dist/scripts/iranti-cli.js +220 -23
  3. package/dist/scripts/iranti-mcp.js +30 -11
  4. package/dist/scripts/seed.js +10 -10
  5. package/dist/src/api/middleware/validation.d.ts +1 -1
  6. package/dist/src/api/middleware/validation.js +1 -1
  7. package/dist/src/api/middleware/validation.js.map +1 -1
  8. package/dist/src/api/routes/knowledge.d.ts.map +1 -1
  9. package/dist/src/api/routes/knowledge.js +29 -1
  10. package/dist/src/api/routes/knowledge.js.map +1 -1
  11. package/dist/src/api/server.js +1 -1
  12. package/dist/src/archivist/index.d.ts.map +1 -1
  13. package/dist/src/archivist/index.js +99 -54
  14. package/dist/src/archivist/index.js.map +1 -1
  15. package/dist/src/attendant/AttendantInstance.d.ts.map +1 -1
  16. package/dist/src/attendant/AttendantInstance.js +17 -6
  17. package/dist/src/attendant/AttendantInstance.js.map +1 -1
  18. package/dist/src/generated/prisma/commonInputTypes.d.ts +181 -50
  19. package/dist/src/generated/prisma/commonInputTypes.d.ts.map +1 -1
  20. package/dist/src/generated/prisma/enums.d.ts +21 -1
  21. package/dist/src/generated/prisma/enums.d.ts.map +1 -1
  22. package/dist/src/generated/prisma/enums.js +19 -0
  23. package/dist/src/generated/prisma/enums.js.map +1 -1
  24. package/dist/src/generated/prisma/internal/class.js +4 -4
  25. package/dist/src/generated/prisma/internal/class.js.map +1 -1
  26. package/dist/src/generated/prisma/internal/prismaNamespace.d.ts +34 -4
  27. package/dist/src/generated/prisma/internal/prismaNamespace.d.ts.map +1 -1
  28. package/dist/src/generated/prisma/internal/prismaNamespace.js +6 -0
  29. package/dist/src/generated/prisma/internal/prismaNamespace.js.map +1 -1
  30. package/dist/src/generated/prisma/internal/prismaNamespaceBrowser.d.ts +6 -0
  31. package/dist/src/generated/prisma/internal/prismaNamespaceBrowser.d.ts.map +1 -1
  32. package/dist/src/generated/prisma/internal/prismaNamespaceBrowser.js +6 -0
  33. package/dist/src/generated/prisma/internal/prismaNamespaceBrowser.js.map +1 -1
  34. package/dist/src/generated/prisma/models/Archive.d.ts +110 -16
  35. package/dist/src/generated/prisma/models/Archive.d.ts.map +1 -1
  36. package/dist/src/generated/prisma/models/KnowledgeEntry.d.ts +100 -3
  37. package/dist/src/generated/prisma/models/KnowledgeEntry.d.ts.map +1 -1
  38. package/dist/src/lib/decay.d.ts +13 -0
  39. package/dist/src/lib/decay.d.ts.map +1 -0
  40. package/dist/src/lib/decay.js +54 -0
  41. package/dist/src/lib/decay.js.map +1 -0
  42. package/dist/src/lib/providers/mock.d.ts.map +1 -1
  43. package/dist/src/lib/providers/mock.js +25 -4
  44. package/dist/src/lib/providers/mock.js.map +1 -1
  45. package/dist/src/librarian/chunker.d.ts +1 -0
  46. package/dist/src/librarian/chunker.d.ts.map +1 -1
  47. package/dist/src/librarian/chunker.js +50 -10
  48. package/dist/src/librarian/chunker.js.map +1 -1
  49. package/dist/src/librarian/index.d.ts +3 -0
  50. package/dist/src/librarian/index.d.ts.map +1 -1
  51. package/dist/src/librarian/index.js +168 -198
  52. package/dist/src/librarian/index.js.map +1 -1
  53. package/dist/src/library/queries.d.ts +36 -7
  54. package/dist/src/library/queries.d.ts.map +1 -1
  55. package/dist/src/library/queries.js +160 -31
  56. package/dist/src/library/queries.js.map +1 -1
  57. package/dist/src/sdk/index.d.ts +38 -3
  58. package/dist/src/sdk/index.d.ts.map +1 -1
  59. package/dist/src/sdk/index.js +131 -61
  60. package/dist/src/sdk/index.js.map +1 -1
  61. package/dist/src/types.d.ts +6 -1
  62. package/dist/src/types.d.ts.map +1 -1
  63. package/package.json +7 -1
  64. package/prisma/migrations/20260314120000_add_temporal_versioning_mvp/migration.sql +61 -0
  65. package/prisma/migrations/20260314164000_add_memory_decay_fields/migration.sql +23 -0
  66. package/prisma/schema.prisma +45 -16
package/README.md CHANGED
@@ -70,6 +70,34 @@ Validated with multiple agent frameworks:
70
70
 
71
71
  Full validation report: [`docs/internal/validation_results.md`](docs/internal/validation_results.md) | Multi-framework details: [`docs/internal/MULTI_FRAMEWORK_VALIDATION.md`](docs/internal/MULTI_FRAMEWORK_VALIDATION.md)
72
72
 
73
+ ### Conflict Benchmark Baseline
74
+
75
+ Iranti now also has an adversarial conflict benchmark that measures contradiction handling rather than basic retrieval.
76
+
77
+ | Suite | Score | Notes |
78
+ |---|---|---|
79
+ | **Direct contradiction** | `4/4` | Same entity+key conflicts are explicitly resolved or escalated |
80
+ | **Temporal conflict** | `3/4` | One known-failing edge remains |
81
+ | **Cascading conflict** | `0/4` | Cross-key contradiction detection not implemented yet |
82
+ | **Multi-hop conflict** | `0/4` | Graph-aware conflict reasoning not implemented yet |
83
+ | **Total** | `7/16 (44%)` | Honest baseline for the current Librarian |
84
+
85
+ Conflict benchmark methodology: [`docs/internal/conflict_benchmark.md`](docs/internal/conflict_benchmark.md)
86
+
87
+ ### Consistency Validation
88
+
89
+ Iranti also now documents and validates its consistency model empirically:
90
+
91
+ | Check | Result |
92
+ |---|---|
93
+ | Concurrent write serialization | `PASS` |
94
+ | Read-after-write visibility | `PASS` |
95
+ | Escalation state integrity | `PASS` |
96
+ | Observe isolation from uncommitted writes | `PASS` |
97
+ | **Total** | `4/4` |
98
+
99
+ Consistency model and validation: [`docs/internal/consistency_model.md`](docs/internal/consistency_model.md)
100
+
73
101
  ### Goal 1: Easy Integration
74
102
 
75
103
  - **Entity**: `project/quantum_bridge`
@@ -108,9 +136,93 @@ Full validation report: [`docs/internal/validation_results.md`](docs/internal/va
108
136
 
109
137
  Full validation report: [`docs/internal/validation_results.md`](docs/internal/validation_results.md)
110
138
 
139
+ ## Gap Analysis
140
+
141
+ Iranti targets a specific gap in the agent infrastructure stack: most competing systems give you semantic retrieval, framework-specific memory, or raw vector storage, but not the same combination of structured fact storage, cross-agent sharing, identity-based lookup, explicit confidence, and developer-visible conflict handling in one self-hostable package.
142
+
143
+ The current competitive case for Iranti is strongest when a team needs memory that behaves more like shared infrastructure than a chat transcript: facts are attached to entities, retrieved deterministically by `entityType/entityId + key`, versioned over time, and made available across agents without framework lock-in.
144
+
145
+ ### Where Iranti Is Differentiated
146
+
147
+ - Identity-first fact retrieval through `entityType/entityId + key`
148
+ - Cross-agent fact sharing as a first-class model
149
+ - Conflict-aware writes through the Librarian
150
+ - Explicit per-fact confidence scores
151
+ - Per-agent memory injection through the Attendant
152
+ - Temporal exact lookup with `asOf` and ordered `history()`
153
+ - Relationship primitives through `relate()`, `getRelated()`, and `getRelatedDeep()`
154
+ - Hybrid retrieval when exact keys are unknown
155
+ - Local install + project binding flow for Claude Code and Codex
156
+ - Published npm / PyPI surfaces with machine-level CLI setup
157
+
158
+ ### Why That Gap Exists
159
+
160
+ The current landscape splits into three buckets:
161
+
162
+ 1. **Memory libraries**
163
+ - Systems like Mem0, Zep, Letta, and framework-native memory layers solve parts of the problem.
164
+ - They usually optimize for semantic retrieval, agent-local memory, or framework integration.
165
+ - They rarely expose deterministic `entity + key` lookup, explicit confidence surfaces, and developer-controlled conflict handling together.
166
+
167
+ 2. **Vector databases**
168
+ - Pinecone, Weaviate, Qdrant, Chroma, Milvus, LanceDB, and `pgvector` solve storage and retrieval infrastructure.
169
+ - They do not, by themselves, solve memory semantics such as conflict resolution, context injection, fact lifecycle, or shared agent-facing state.
170
+
171
+ 3. **Multi-agent frameworks**
172
+ - CrewAI, LangGraph, AutoGen, CAMEL, MetaGPT, and similar frameworks often include some memory support.
173
+ - In practice, that memory is usually framework-coupled, shallow on conflict semantics, and difficult to reuse outside the framework that created it.
174
+
175
+ ### Main Gaps
176
+
177
+ 1. **Operational maturity**
178
+ - Local PostgreSQL setup is still a real source of friction.
179
+ - The product needs stronger diagnostics, connection recovery, and less dependence on users debugging local database state by hand.
180
+
181
+ 2. **Onboarding still has sharp edges**
182
+ - `iranti setup` is materially better than before, but first-run still assumes too much infrastructure literacy.
183
+ - Managed Postgres paths, cleaner bootstrap verification, and fewer environment-level surprises are still needed.
184
+
185
+ 3. **No operator UI yet**
186
+ - Iranti is still CLI-first.
187
+ - There is no control plane yet for provider keys, project bindings, integrations, memory inspection, and escalation review.
188
+
189
+ 4. **Adoption proof is still early**
190
+ - The repo has validation experiments and real local end-to-end usage, but broad production adoption is still limited.
191
+ - The next product truth has to come from external users and real workloads, not more speculative architecture alone.
192
+
193
+ 5. **Hosted product is not built**
194
+ - Open-source/local infrastructure is the active surface today.
195
+ - Hosted deployment, multi-tenant operations, billing, and cloud onboarding remain future work.
196
+
197
+ 6. **Graph-native reasoning is still limited**
198
+ - Iranti supports explicit entity relationships today.
199
+ - It does not yet compete with graph-first systems on temporal graph traversal or graph-native reasoning depth.
200
+
201
+ 7. **Memory extraction is not the main model**
202
+ - Iranti supports structured writes and ingest/chunking, but it is not primarily a "dump arbitrary conversations in and auto-magically derive perfect memory" system.
203
+ - That is a deliberate tradeoff in favor of explicit, inspectable facts, but it increases integration work.
204
+
205
+ ### Current Position
206
+
207
+ Iranti is strongest today as infrastructure for developers building multi-agent systems who need shared, structured, queryable memory rather than pure semantic recall. The current evidence base is now more concrete than a positioning claim alone:
208
+
209
+ - `16/16` fictional-fact transfer in retrieval validation
210
+ - `7/16 (44%)` on an adversarial conflict benchmark
211
+ - `4/4` on empirical consistency validation for serialized writes and read visibility
212
+
213
+ That is not a claim that multi-agent memory is solved. It is a claim that Iranti now has reproducible evidence for three things at once:
214
+
215
+ - exact cross-agent fact transfer works
216
+ - same-key conflicting writes are serialized and observable
217
+ - conflict handling quality is measurable, including clearly documented failure modes
218
+
219
+ The next leverage is still product simplicity: setup, operations, and day-to-day inspection need to be simple enough that real users keep Iranti in the loop.
220
+
111
221
  ## Quickstart
112
222
 
113
- **Requirements**: Node.js 18+, Docker, Python 3.8+
223
+ **Requirements**: Node.js 18+, PostgreSQL, Python 3.8+
224
+
225
+ Docker is optional. It is one local way to run PostgreSQL if you do not already have a database.
114
226
 
115
227
  ```bash
116
228
  # 1. Clone and configure
@@ -136,6 +248,9 @@ npm run api # Runs on port 3001
136
248
 
137
249
  # 5. Install Python client
138
250
  pip install iranti
251
+
252
+ # Optional: install the TypeScript client
253
+ npm install @iranti/sdk
139
254
  ```
140
255
 
141
256
  ### Archivist Scheduling Knobs
@@ -233,7 +348,11 @@ iranti install --scope user
233
348
  `iranti setup` is the recommended first-run path. It walks through:
234
349
  - shared vs isolated runtime setup
235
350
  - instance creation or update
236
- - database URL entry
351
+ - API port selection with conflict detection and next-free suggestions
352
+ - database onboarding:
353
+ - existing Postgres
354
+ - managed Postgres
355
+ - optional Docker-hosted Postgres for local development
237
356
  - provider API keys
238
357
  - Iranti client API key generation
239
358
  - one or more project bindings
@@ -242,8 +361,11 @@ iranti install --scope user
242
361
  For automation:
243
362
  - `iranti setup --defaults` uses sensible defaults plus environment/flag input, but still requires a real `DATABASE_URL`.
244
363
  - `iranti setup --config <file>` reads a JSON setup plan for repeatable bootstrap.
364
+ - `--bootstrap-db` runs migrations and seeding during automated setup when the database is reachable.
245
365
  - Example config: [docs/guides/iranti.setup.example.json](docs/guides/iranti.setup.example.json)
246
366
 
367
+ Default API port remains `3001`. The setup wizard now warns when that port is already in use and suggests the next free port instead of forcing users to debug the collision manually.
368
+
247
369
  Defaults:
248
370
  - Windows user scope: `%USERPROFILE%\\.iranti`
249
371
  - Windows system scope: `%ProgramData%\\Iranti`
@@ -367,6 +489,51 @@ for fact in facts:
367
489
  print(f"[{fact['key']}] {fact['summary']} (confidence: {fact['confidence']})")
368
490
  ```
369
491
 
492
+ ### Graph Traversal
493
+
494
+ ```python
495
+ from clients.python.iranti import IrantiClient
496
+
497
+ client = IrantiClient(base_url="http://localhost:3001", api_key="your_api_key_here")
498
+
499
+ # Agent 1 writes facts and links them into a graph.
500
+ client.write("researcher/jane_smith", "affiliation", {"lab": "CSAIL"}, "Jane Smith is affiliated with CSAIL", 90, "OpenAlex", "research_agent")
501
+ client.write("project/quantum_bridge", "status", {"phase": "active"}, "Quantum Bridge is active", 88, "project_brief", "research_agent")
502
+
503
+ client.relate("researcher/jane_smith", "MEMBER_OF", "lab/csail", created_by="research_agent")
504
+ client.relate("lab/csail", "LEADS", "project/quantum_bridge", created_by="research_agent")
505
+
506
+ # Agent 2 starts cold and traverses outward from Jane Smith.
507
+ one_hop = client.related("researcher/jane_smith")
508
+ labs = [f"{r['toType']}/{r['toId']}" for r in one_hop if r["relationshipType"] == "MEMBER_OF"]
509
+
510
+ projects = []
511
+ for lab in labs:
512
+ for rel in client.related(lab):
513
+ if rel["relationshipType"] == "LEADS":
514
+ project = f"{rel['toType']}/{rel['toId']}"
515
+ status = client.query(project, "status")
516
+ projects.append((project, status.value["phase"]))
517
+
518
+ print(projects)
519
+ # Agent 2 learned which project Jane Smith is connected to without being told the project directly.
520
+ ```
521
+
522
+ ### Relationship Types
523
+
524
+ Relationship types are caller-defined strings. Common conventions:
525
+
526
+ | Relationship Type | Meaning |
527
+ |---|---|
528
+ | `MEMBER_OF` | Entity belongs to a team, lab, org, or group |
529
+ | `PART_OF` | Entity is a component or sub-unit of another entity |
530
+ | `AUTHORED` | Person or agent created a document, paper, or artifact |
531
+ | `LEADS` | Person, team, or org leads a project or effort |
532
+ | `DEPENDS_ON` | Project, service, or task depends on another entity |
533
+ | `REPORTS_TO` | Directed reporting relationship between people or agents |
534
+
535
+ Use uppercase snake case for consistency. Iranti does not enforce a fixed ontology here; the calling application owns the relationship vocabulary.
536
+
370
537
  ### Hybrid Search
371
538
 
372
539
  ```python
@@ -519,7 +686,7 @@ Iranti has four internal components:
519
686
 
520
687
  | Component | Role |
521
688
  |---|---|
522
- | **Library** | PostgreSQL knowledge base. Active truth in `knowledge_base` with full provenance in `archive`; archived rows are retained and marked `[ARCHIVED]` in active storage. |
689
+ | **Library** | PostgreSQL knowledge base. Current truth lives in `knowledge_base`; closed and contested intervals live in `archive`. |
523
690
  | **Librarian** | Manages all writes. Detects conflicts, reasons about resolution, escalates when uncertain. |
524
691
  | **Attendant** | Per-agent working memory manager. Implements `attend()`, `observe()`, and `handshake()` APIs. |
525
692
  | **Archivist** | Periodic cleanup. Archives expired and low-confidence entries. Processes human-resolved conflicts. |
@@ -529,7 +696,7 @@ Iranti has four internal components:
529
696
  Express server on port 3001 with endpoints:
530
697
 
531
698
  - `POST /kb/write` - Write atomic fact
532
- - `POST /kb/ingest` - Ingest raw text, auto-chunk into facts
699
+ - `POST /kb/ingest` - Ingest raw text for one entity, auto-chunk into facts with per-fact confidence and per-fact write outcomes
533
700
  - `GET /kb/query/:entityType/:entityId/:key` - Query specific fact
534
701
  - `GET /kb/query/:entityType/:entityId` - Query all facts for entity
535
702
  - `GET /kb/search` - Hybrid search across facts
@@ -549,8 +716,8 @@ All endpoints require `X-Iranti-Key` header for authentication.
549
716
  Six PostgreSQL tables:
550
717
 
551
718
  ```
552
- knowledge_base - active truth (archived rows retained with confidence=0)
553
- archive - full provenance history, never deleted
719
+ knowledge_base - current truth (one live row per entity/key)
720
+ archive - temporal and provenance history for superseded, contradicted, escalated, and expired rows
554
721
  entity_relationships - directional graph: MEMBER_OF, PART_OF, AUTHORED, etc.
555
722
  entities - canonical entity identity registry
556
723
  entity_aliases - normalized aliases mapped to canonical entities
@@ -559,7 +726,7 @@ write_receipts - idempotency receipts for requestId replay safety
559
726
 
560
727
  New entity types, relationship types, and fact keys do not require migrations; they are caller-defined strings.
561
728
 
562
- **Archive semantics**: When an entry is archived, it remains in knowledge_base with confidence set to 0 and summary marked as `[ARCHIVED]`. A full copy is written to the archive table for traceability. Nothing is ever truly deleted.
729
+ **Archive semantics**: When a current fact is superseded or contested, the current row is removed from `knowledge_base` and a closed historical interval is written to `archive`. Temporal queries use `validFrom` / `validUntil` plus archive metadata to answer point-in-time reads.
563
730
 
564
731
  ---
565
732
 
@@ -11,6 +11,7 @@ const path_1 = __importDefault(require("path"));
11
11
  const child_process_1 = require("child_process");
12
12
  const promises_2 = __importDefault(require("readline/promises"));
13
13
  const stream_1 = require("stream");
14
+ const net_1 = __importDefault(require("net"));
14
15
  const client_1 = require("../src/library/client");
15
16
  const apiKeys_1 = require("../src/security/apiKeys");
16
17
  const PROVIDER_ENV_KEYS = {
@@ -135,29 +136,43 @@ function resolveInstallRoot(args, scope) {
135
136
  return userRoot;
136
137
  }
137
138
  function getPackageVersion() {
139
+ const pkgPath = path_1.default.join(packageRoot(), 'package.json');
140
+ if (fs_1.default.existsSync(pkgPath)) {
141
+ try {
142
+ const raw = fs_1.default.readFileSync(pkgPath, 'utf-8');
143
+ const pkg = JSON.parse(raw);
144
+ return String(pkg.version ?? '0.0.0');
145
+ }
146
+ catch {
147
+ return '0.0.0';
148
+ }
149
+ }
150
+ return '0.0.0';
151
+ }
152
+ function packageRoot() {
138
153
  let dir = __dirname;
139
- for (let i = 0; i < 5; i++) {
154
+ for (let i = 0; i < 6; i++) {
140
155
  const pkgPath = path_1.default.join(dir, 'package.json');
141
156
  if (fs_1.default.existsSync(pkgPath)) {
142
- try {
143
- const raw = fs_1.default.readFileSync(pkgPath, 'utf-8');
144
- const pkg = JSON.parse(raw);
145
- return String(pkg.version ?? '0.0.0');
146
- }
147
- catch {
148
- return '0.0.0';
149
- }
157
+ return dir;
150
158
  }
151
159
  const parent = path_1.default.dirname(dir);
152
160
  if (parent === dir)
153
161
  break;
154
162
  dir = parent;
155
163
  }
156
- return '0.0.0';
164
+ return process.cwd();
157
165
  }
158
166
  function builtScriptPath(scriptName) {
159
167
  return path_1.default.resolve(__dirname, `${scriptName}.js`);
160
168
  }
169
+ function formatSetupBootstrapFailure(error) {
170
+ const reason = error instanceof Error ? error.message : String(error);
171
+ return new Error(`Database bootstrap failed after instance configuration. ` +
172
+ `Common causes are a non-empty database that Prisma has not baselined yet, or a PostgreSQL server without the pgvector extension installed. ` +
173
+ `Re-run setup without --bootstrap-db, or point Iranti at a fresh pgvector-capable database. ` +
174
+ `Underlying error: ${reason}`);
175
+ }
161
176
  async function handoffToScript(scriptName, rawArgs) {
162
177
  const builtPath = builtScriptPath(scriptName);
163
178
  if (fs_1.default.existsSync(builtPath)) {
@@ -203,6 +218,34 @@ async function handoffToScript(scriptName, rawArgs) {
203
218
  });
204
219
  });
205
220
  }
221
+ async function runBundledScript(scriptName, rawArgs, extraEnv) {
222
+ const builtPath = builtScriptPath(scriptName);
223
+ if (!fs_1.default.existsSync(builtPath)) {
224
+ throw new Error(`Unable to locate bundled script: ${scriptName}`);
225
+ }
226
+ await new Promise((resolve, reject) => {
227
+ const child = (0, child_process_1.spawn)(process.execPath, [builtPath, ...rawArgs], {
228
+ stdio: 'inherit',
229
+ env: {
230
+ ...process.env,
231
+ ...extraEnv,
232
+ },
233
+ cwd: packageRoot(),
234
+ });
235
+ child.on('error', reject);
236
+ child.on('exit', (code, signal) => {
237
+ if (signal) {
238
+ reject(new Error(`${scriptName} terminated with signal ${signal}`));
239
+ return;
240
+ }
241
+ if ((code ?? 0) !== 0) {
242
+ reject(new Error(`${scriptName} exited with code ${code ?? 1}`));
243
+ return;
244
+ }
245
+ resolve();
246
+ });
247
+ });
248
+ }
206
249
  async function ensureDir(dir) {
207
250
  await promises_1.default.mkdir(dir, { recursive: true });
208
251
  }
@@ -634,6 +677,117 @@ function hasCodexInstalled() {
634
677
  return false;
635
678
  }
636
679
  }
680
+ function hasDockerInstalled() {
681
+ try {
682
+ const proc = process.platform === 'win32'
683
+ ? (0, child_process_1.spawnSync)(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', 'docker --version'], { stdio: 'ignore' })
684
+ : (0, child_process_1.spawnSync)('docker', ['--version'], { stdio: 'ignore' });
685
+ return proc.status === 0;
686
+ }
687
+ catch {
688
+ return false;
689
+ }
690
+ }
691
+ async function isPortAvailable(port, host = '127.0.0.1') {
692
+ return await new Promise((resolve) => {
693
+ const server = net_1.default.createServer();
694
+ server.unref();
695
+ server.on('error', () => resolve(false));
696
+ server.listen(port, host, () => {
697
+ server.close(() => resolve(true));
698
+ });
699
+ });
700
+ }
701
+ async function findNextAvailablePort(start, host = '127.0.0.1', maxSteps = 50) {
702
+ for (let port = start; port < start + maxSteps; port += 1) {
703
+ if (await isPortAvailable(port, host)) {
704
+ return port;
705
+ }
706
+ }
707
+ throw new Error(`No available port found in range ${start}-${start + maxSteps - 1}.`);
708
+ }
709
+ async function chooseAvailablePort(session, promptText, preferredPort, allowOccupiedCurrent = false) {
710
+ let suggested = preferredPort;
711
+ if (!allowOccupiedCurrent && !(await isPortAvailable(preferredPort))) {
712
+ suggested = await findNextAvailablePort(preferredPort + 1);
713
+ console.log(`${warnLabel()} Port ${preferredPort} is already in use. Suggested port: ${suggested}`);
714
+ }
715
+ while (true) {
716
+ const raw = await promptNonEmpty(session, promptText, String(suggested));
717
+ const parsed = Number.parseInt(raw, 10);
718
+ if (!Number.isFinite(parsed) || parsed <= 0) {
719
+ console.log(`${warnLabel()} Port must be a positive integer.`);
720
+ continue;
721
+ }
722
+ if (allowOccupiedCurrent && parsed === preferredPort) {
723
+ return parsed;
724
+ }
725
+ if (await isPortAvailable(parsed)) {
726
+ return parsed;
727
+ }
728
+ const next = await findNextAvailablePort(parsed + 1);
729
+ console.log(`${warnLabel()} Port ${parsed} is already in use. Try ${next} instead.`);
730
+ suggested = next;
731
+ }
732
+ }
733
+ async function waitForTcpPort(host, port, timeoutMs) {
734
+ const deadline = Date.now() + timeoutMs;
735
+ while (Date.now() < deadline) {
736
+ const ready = await new Promise((resolve) => {
737
+ const socket = net_1.default.connect({ host, port });
738
+ socket.once('connect', () => {
739
+ socket.destroy();
740
+ resolve(true);
741
+ });
742
+ socket.once('error', () => {
743
+ socket.destroy();
744
+ resolve(false);
745
+ });
746
+ });
747
+ if (ready)
748
+ return;
749
+ await new Promise((resolve) => setTimeout(resolve, 1000));
750
+ }
751
+ throw new Error(`Timed out waiting for ${host}:${port} to accept TCP connections.`);
752
+ }
753
+ async function runDockerPostgresContainer(options) {
754
+ const inspect = process.platform === 'win32'
755
+ ? (0, child_process_1.spawnSync)(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', `docker ps -a --format "{{.Names}}"`], { encoding: 'utf8' })
756
+ : (0, child_process_1.spawnSync)('docker', ['ps', '-a', '--format', '{{.Names}}'], { encoding: 'utf8' });
757
+ const names = (inspect.stdout ?? '').split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
758
+ if (names.includes(options.containerName)) {
759
+ const start = process.platform === 'win32'
760
+ ? (0, child_process_1.spawnSync)(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', `docker start ${options.containerName}`], { stdio: 'inherit' })
761
+ : (0, child_process_1.spawnSync)('docker', ['start', options.containerName], { stdio: 'inherit' });
762
+ if (start.status !== 0) {
763
+ throw new Error(`Failed to start existing Docker container '${options.containerName}'.`);
764
+ }
765
+ }
766
+ else {
767
+ const args = [
768
+ 'run',
769
+ '-d',
770
+ '--name',
771
+ options.containerName,
772
+ '-e',
773
+ `POSTGRES_USER=postgres`,
774
+ '-e',
775
+ `POSTGRES_PASSWORD=${options.password}`,
776
+ '-e',
777
+ `POSTGRES_DB=${options.database}`,
778
+ '-p',
779
+ `${options.hostPort}:5432`,
780
+ 'pgvector/pgvector:pg16',
781
+ ];
782
+ const result = process.platform === 'win32'
783
+ ? (0, child_process_1.spawnSync)(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', ['docker', ...args].join(' ')], { stdio: 'inherit' })
784
+ : (0, child_process_1.spawnSync)('docker', args, { stdio: 'inherit' });
785
+ if (result.status !== 0) {
786
+ throw new Error(`Failed to start Docker PostgreSQL container '${options.containerName}'.`);
787
+ }
788
+ }
789
+ await waitForTcpPort('127.0.0.1', options.hostPort, 30000);
790
+ }
637
791
  async function executeSetupPlan(plan) {
638
792
  await ensureRuntimeInstalled(plan.root, plan.scope);
639
793
  const configured = await ensureInstanceConfigured(plan.root, plan.instanceName, {
@@ -643,6 +797,17 @@ async function executeSetupPlan(plan) {
643
797
  providerKeys: plan.providerKeys,
644
798
  apiKey: plan.apiKey,
645
799
  });
800
+ if (plan.bootstrapDatabase) {
801
+ try {
802
+ await runBundledScript('setup', [], {
803
+ DATABASE_URL: plan.databaseUrl,
804
+ IRANTI_ESCALATION_DIR: path_1.default.join(configured.instanceDir, 'escalation'),
805
+ });
806
+ }
807
+ catch (error) {
808
+ throw formatSetupBootstrapFailure(error);
809
+ }
810
+ }
646
811
  const bindings = [];
647
812
  for (const project of plan.projects) {
648
813
  const projectPath = path_1.default.resolve(project.path);
@@ -737,6 +902,7 @@ function parseSetupConfig(filePath) {
737
902
  projects,
738
903
  codex: Boolean(raw?.codex),
739
904
  codexAgent: raw?.codexAgent ? sanitizeIdentifier(String(raw.codexAgent), 'codex_code') : undefined,
905
+ bootstrapDatabase: Boolean(raw?.bootstrapDatabase),
740
906
  };
741
907
  }
742
908
  function defaultsSetupPlan(args) {
@@ -791,6 +957,7 @@ function defaultsSetupPlan(args) {
791
957
  projects,
792
958
  codex: hasFlag(args, 'codex'),
793
959
  codexAgent: sanitizeIdentifier(getFlag(args, 'codex-agent') ?? 'codex_code', 'codex_code'),
960
+ bootstrapDatabase: hasFlag(args, 'bootstrap-db'),
794
961
  };
795
962
  }
796
963
  function detectProviderKey(provider, env) {
@@ -1054,22 +1221,51 @@ async function setupCommand(args) {
1054
1221
  else {
1055
1222
  console.log(`${infoLabel()} Creating new instance '${instanceName}'.`);
1056
1223
  }
1057
- let port = 3001;
1224
+ const existingPort = Number.parseInt(existingInstance?.env.IRANTI_PORT ?? '3001', 10);
1225
+ const port = await chooseAvailablePort(prompt, 'Iranti API port', existingPort, Boolean(existingInstance));
1226
+ const dockerAvailable = hasDockerInstalled();
1227
+ let dbUrl = '';
1228
+ let bootstrapDatabase = false;
1058
1229
  while (true) {
1059
- const raw = await promptNonEmpty(prompt, 'Port', existingInstance?.env.IRANTI_PORT ?? '3001');
1060
- const parsed = Number.parseInt(raw, 10);
1061
- if (Number.isFinite(parsed) && parsed > 0) {
1062
- port = parsed;
1230
+ const defaultMode = dockerAvailable ? 'docker' : 'existing';
1231
+ const dbMode = (await prompt.line('Database setup mode: existing, managed, or docker', defaultMode) ?? defaultMode).trim().toLowerCase();
1232
+ if (dbMode === 'existing' || dbMode === 'managed') {
1233
+ while (true) {
1234
+ dbUrl = await promptNonEmpty(prompt, 'DATABASE_URL', existingInstance?.env.DATABASE_URL ?? `postgresql://postgres:yourpassword@localhost:5432/iranti_${instanceName}`);
1235
+ if (!detectPlaceholder(dbUrl))
1236
+ break;
1237
+ console.log(`${warnLabel()} DATABASE_URL still looks like a placeholder. Enter a real connection string before finishing setup.`);
1238
+ }
1239
+ bootstrapDatabase = await promptYesNo(prompt, 'Run migrations and seed the database now?', true);
1063
1240
  break;
1064
1241
  }
1065
- console.log(`${warnLabel()} Port must be a positive integer.`);
1066
- }
1067
- let dbUrl = '';
1068
- while (true) {
1069
- dbUrl = await promptNonEmpty(prompt, 'DATABASE_URL', existingInstance?.env.DATABASE_URL ?? `postgresql://postgres:yourpassword@localhost:5432/iranti_${instanceName}`);
1070
- if (!detectPlaceholder(dbUrl))
1242
+ if (dbMode === 'docker') {
1243
+ if (!dockerAvailable) {
1244
+ console.log(`${warnLabel()} Docker is not installed or not on PATH. Choose existing or managed instead.`);
1245
+ continue;
1246
+ }
1247
+ const dbHostPort = await chooseAvailablePort(prompt, 'Docker PostgreSQL host port', 5432, false);
1248
+ const dbName = sanitizeIdentifier(await promptNonEmpty(prompt, 'Docker PostgreSQL database name', `iranti_${instanceName}`), `iranti_${instanceName}`);
1249
+ const dbPassword = await promptRequiredSecret(prompt, 'Docker PostgreSQL password');
1250
+ const containerName = sanitizeIdentifier(await promptNonEmpty(prompt, 'Docker container name', `iranti_${instanceName}_db`), `iranti_${instanceName}_db`);
1251
+ dbUrl = `postgresql://postgres:${dbPassword}@localhost:${dbHostPort}/${dbName}`;
1252
+ console.log(`${infoLabel()} Docker will be used only for PostgreSQL. Iranti itself does not require Docker once a PostgreSQL database is available.`);
1253
+ if (await promptYesNo(prompt, `Start or reuse Docker container '${containerName}' now?`, true)) {
1254
+ await runDockerPostgresContainer({
1255
+ containerName,
1256
+ hostPort: dbHostPort,
1257
+ password: dbPassword,
1258
+ database: dbName,
1259
+ });
1260
+ console.log(`${okLabel()} Docker PostgreSQL ready at localhost:${dbHostPort}`);
1261
+ bootstrapDatabase = true;
1262
+ }
1263
+ else {
1264
+ bootstrapDatabase = await promptYesNo(prompt, 'Will you start PostgreSQL separately before first run?', false);
1265
+ }
1071
1266
  break;
1072
- console.log(`${warnLabel()} DATABASE_URL still looks like a placeholder. Enter a real connection string before finishing setup.`);
1267
+ }
1268
+ console.log(`${warnLabel()} Choose one of: existing, managed, docker.`);
1073
1269
  }
1074
1270
  let provider = normalizeProvider(existingInstance?.env.LLM_PROVIDER ?? 'openai') ?? 'openai';
1075
1271
  while (true) {
@@ -1160,6 +1356,7 @@ async function setupCommand(args) {
1160
1356
  projects,
1161
1357
  codex,
1162
1358
  codexAgent: projects[0]?.agentId,
1359
+ bootstrapDatabase,
1163
1360
  });
1164
1361
  });
1165
1362
  if (!result) {
@@ -1874,7 +2071,7 @@ function printHelp() {
1874
2071
 
1875
2072
  Machine-level:
1876
2073
  iranti install [--scope user|system] [--root <path>]
1877
- iranti setup [--scope user|system] [--root <path>] [--config <file> | --defaults]
2074
+ iranti setup [--scope user|system] [--root <path>] [--config <file> | --defaults] [--db-url <url>] [--bootstrap-db]
1878
2075
 
1879
2076
  Instance-level:
1880
2077
  iranti instance create <name> [--port 3001] [--db-url <url>] [--api-key <token>] [--provider <name>] [--provider-key <token>] [--scope user|system]