@aiwg/cli 2026.8.3 → 2026.8.5

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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Cross-orchestrator scheduling and admission for shared execution hosts.
3
+ *
4
+ * The store is the global serialization boundary. Multiple AIWG processes must
5
+ * use the same durable implementation; the in-memory store is for tests and
6
+ * single-process embedding only. Executor substrates report capacity and run
7
+ * admitted work, but do not own this policy.
8
+ *
9
+ * @implements #1566
10
+ */
11
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+ export class InMemoryAdmissionStore {
14
+ snapshot = { revision: 0, records: {} };
15
+ transact(mutate) {
16
+ const draft = structuredClone(this.snapshot);
17
+ const result = mutate(draft);
18
+ draft.revision += 1;
19
+ this.snapshot = draft;
20
+ return result;
21
+ }
22
+ read() {
23
+ return structuredClone(this.snapshot);
24
+ }
25
+ }
26
+ /** Durable cross-process store. The lock is deliberately non-blocking: a
27
+ * concurrent writer receives a conflict and retries through its control loop. */
28
+ export class FileAdmissionStore {
29
+ path;
30
+ constructor(path) {
31
+ this.path = path;
32
+ }
33
+ transact(mutate) {
34
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
35
+ const lockPath = `${this.path}.lock`;
36
+ let descriptor;
37
+ try {
38
+ descriptor = openSync(lockPath, 'wx', 0o600);
39
+ }
40
+ catch (error) {
41
+ if (error.code === 'EEXIST') {
42
+ throw new Error('shared-host admission store is busy; retry the request');
43
+ }
44
+ throw error;
45
+ }
46
+ try {
47
+ const snapshot = this.readUnsafe();
48
+ const result = mutate(snapshot);
49
+ snapshot.revision += 1;
50
+ const temporary = `${this.path}.${process.pid}.tmp`;
51
+ writeFileSync(temporary, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 });
52
+ renameSync(temporary, this.path);
53
+ return result;
54
+ }
55
+ finally {
56
+ closeSync(descriptor);
57
+ try {
58
+ unlinkSync(lockPath);
59
+ }
60
+ catch { /* best-effort lock cleanup */ }
61
+ }
62
+ }
63
+ read() {
64
+ return structuredClone(this.readUnsafe());
65
+ }
66
+ readUnsafe() {
67
+ if (!existsSync(this.path))
68
+ return { revision: 0, records: {} };
69
+ return JSON.parse(readFileSync(this.path, 'utf8'));
70
+ }
71
+ }
72
+ const terminalStates = new Set([
73
+ 'denied', 'cancelled', 'timed-out', 'preempted',
74
+ ]);
75
+ export class SharedHostScheduler {
76
+ store;
77
+ policy;
78
+ clock;
79
+ constructor(store, policy, clock = Date.now) {
80
+ this.store = store;
81
+ this.policy = policy;
82
+ this.clock = clock;
83
+ if (!Number.isInteger(policy.maxConcurrent) || policy.maxConcurrent < 1) {
84
+ throw new Error('maxConcurrent must be a positive integer');
85
+ }
86
+ if (policy.leaseTtlMs < 1 || policy.agingIntervalMs < 1) {
87
+ throw new Error('leaseTtlMs and agingIntervalMs must be positive');
88
+ }
89
+ }
90
+ submit(request) {
91
+ this.validateRequest(request);
92
+ return this.store.transact(snapshot => {
93
+ const existing = snapshot.records[request.requestId];
94
+ if (existing) {
95
+ if (!sameRequest(existing, request)) {
96
+ throw new Error(`request '${request.requestId}' conflicts with an existing admission`);
97
+ }
98
+ return existing;
99
+ }
100
+ snapshot.records[request.requestId] = {
101
+ ...structuredClone(request),
102
+ state: 'queued',
103
+ revision: 1,
104
+ reason: 'awaiting shared-host capacity',
105
+ };
106
+ this.reconcile(snapshot);
107
+ return structuredClone(snapshot.records[request.requestId]);
108
+ });
109
+ }
110
+ reconcileNow() {
111
+ return this.store.transact(snapshot => {
112
+ this.reconcile(snapshot);
113
+ return structuredClone(snapshot);
114
+ });
115
+ }
116
+ renew(requestId) {
117
+ return this.store.transact(snapshot => {
118
+ const record = this.required(snapshot, requestId);
119
+ if (record.state !== 'admitted')
120
+ throw new Error(`cannot renew ${record.state} admission`);
121
+ record.leaseExpiresAt = new Date(this.clock() + this.policy.leaseTtlMs).toISOString();
122
+ record.revision += 1;
123
+ record.reason = 'lease renewed';
124
+ return structuredClone(record);
125
+ });
126
+ }
127
+ release(requestId) {
128
+ return this.store.transact(snapshot => {
129
+ const record = this.required(snapshot, requestId);
130
+ delete snapshot.records[requestId];
131
+ if (record.state === 'admitted')
132
+ this.reconcile(snapshot);
133
+ return structuredClone(snapshot);
134
+ });
135
+ }
136
+ cancel(requestId) {
137
+ return this.store.transact(snapshot => {
138
+ const record = this.required(snapshot, requestId);
139
+ if (terminalStates.has(record.state))
140
+ return structuredClone(record);
141
+ record.state = 'cancelled';
142
+ record.reason = 'cancelled by orchestrator';
143
+ record.finishedAt = new Date(this.clock()).toISOString();
144
+ record.revision += 1;
145
+ this.reconcile(snapshot);
146
+ return structuredClone(record);
147
+ });
148
+ }
149
+ snapshot() {
150
+ return this.store.read();
151
+ }
152
+ reconcile(snapshot) {
153
+ const now = this.clock();
154
+ for (const record of Object.values(snapshot.records)) {
155
+ if (record.state === 'admitted' && Date.parse(record.leaseExpiresAt ?? '') <= now) {
156
+ record.state = 'timed-out';
157
+ record.reason = 'admission lease expired; capacity recovered';
158
+ record.finishedAt = new Date(now).toISOString();
159
+ record.revision += 1;
160
+ }
161
+ else if (record.state === 'queued' && Date.parse(record.submittedAt) + record.queueTimeoutMs <= now) {
162
+ record.state = 'timed-out';
163
+ record.reason = 'queue deadline elapsed';
164
+ record.finishedAt = new Date(now).toISOString();
165
+ record.revision += 1;
166
+ }
167
+ }
168
+ let queued = Object.values(snapshot.records)
169
+ .filter(record => record.state === 'queued')
170
+ .sort((a, b) => this.compare(a, b, now));
171
+ for (const candidate of queued) {
172
+ if (!this.hasCapacity(snapshot, candidate)) {
173
+ if (this.policy.allowPreemption)
174
+ this.tryPreempt(snapshot, candidate, now);
175
+ }
176
+ if (!this.hasCapacity(snapshot, candidate))
177
+ continue;
178
+ candidate.state = 'admitted';
179
+ candidate.reason = 'admitted by shared-host policy';
180
+ candidate.admittedAt = new Date(now).toISOString();
181
+ candidate.leaseExpiresAt = new Date(now + this.policy.leaseTtlMs).toISOString();
182
+ candidate.revision += 1;
183
+ }
184
+ queued = [];
185
+ }
186
+ tryPreempt(snapshot, candidate, now) {
187
+ const victims = Object.values(snapshot.records)
188
+ .filter(record => record.state === 'admitted' && record.preemptible === true)
189
+ .sort((a, b) => this.compare(b, a, now));
190
+ const victim = victims.find(record => {
191
+ if (this.effectivePriority(record, now) >= this.effectivePriority(candidate, now))
192
+ return false;
193
+ const priorState = record.state;
194
+ record.state = 'preempted';
195
+ const freesRequiredCapacity = this.hasCapacity(snapshot, candidate);
196
+ record.state = priorState;
197
+ return freesRequiredCapacity;
198
+ });
199
+ if (!victim)
200
+ return;
201
+ victim.state = 'preempted';
202
+ victim.reason = `preempted by higher-priority request '${candidate.requestId}'`;
203
+ victim.preemptedBy = candidate.requestId;
204
+ victim.finishedAt = new Date(now).toISOString();
205
+ victim.revision += 1;
206
+ }
207
+ hasCapacity(snapshot, candidate) {
208
+ const active = Object.values(snapshot.records).filter(record => record.state === 'admitted');
209
+ if (active.length >= this.policy.maxConcurrent)
210
+ return false;
211
+ if (!belowQuota(active, 'environment', candidate.environment, this.policy.environmentQuotas))
212
+ return false;
213
+ if (!belowQuota(active, 'provider', candidate.provider, this.policy.providerQuotas))
214
+ return false;
215
+ const runtimeQuotas = {
216
+ host: this.policy.defaultHostQuota ?? 1,
217
+ ...this.policy.runtimeQuotas,
218
+ };
219
+ return belowQuota(active, 'runtimeKind', candidate.runtimeKind, runtimeQuotas);
220
+ }
221
+ effectivePriority(record, now) {
222
+ const waited = Math.max(0, now - Date.parse(record.submittedAt));
223
+ return record.priority + Math.floor(waited / this.policy.agingIntervalMs);
224
+ }
225
+ compare(a, b, now) {
226
+ return this.effectivePriority(b, now) - this.effectivePriority(a, now)
227
+ || Date.parse(a.submittedAt) - Date.parse(b.submittedAt)
228
+ || a.requestId.localeCompare(b.requestId);
229
+ }
230
+ required(snapshot, requestId) {
231
+ const record = snapshot.records[requestId];
232
+ if (!record)
233
+ throw new Error(`unknown admission request '${requestId}'`);
234
+ return record;
235
+ }
236
+ validateRequest(request) {
237
+ if (!request.requestId || !request.orchestratorId || !request.environment || !request.provider) {
238
+ throw new Error('request identity, orchestrator, environment, and provider are required');
239
+ }
240
+ if (!Number.isFinite(request.priority))
241
+ throw new Error('priority must be finite');
242
+ if (!Number.isFinite(Date.parse(request.submittedAt)))
243
+ throw new Error('submittedAt must be a timestamp');
244
+ if (request.queueTimeoutMs < 1)
245
+ throw new Error('queueTimeoutMs must be positive');
246
+ }
247
+ }
248
+ function belowQuota(active, field, value, quotas) {
249
+ const quota = quotas?.[String(value)];
250
+ if (quota === undefined)
251
+ return true;
252
+ return active.filter(record => record[field] === value).length < quota;
253
+ }
254
+ function sameRequest(record, request) {
255
+ return record.orchestratorId === request.orchestratorId
256
+ && record.environment === request.environment
257
+ && record.provider === request.provider
258
+ && record.runtimeKind === request.runtimeKind;
259
+ }
260
+ //# sourceMappingURL=shared-host-scheduler.js.map
@@ -37,6 +37,58 @@
37
37
  * @issue #972
38
38
  */
39
39
  const DEFAULT_MCP_SERVER = 'fortemi';
40
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
41
+ export function resolveMcpRequestHeaders(server, environment = process.env) {
42
+ const headers = { ...(server.headers ?? {}) };
43
+ for (const [header, envName] of Object.entries(server.headerEnv ?? {})) {
44
+ if (!ENV_NAME.test(envName)) {
45
+ throw new Error(`storage(fortemi): invalid environment variable reference "${envName}"`);
46
+ }
47
+ const value = environment[envName];
48
+ if (!value) {
49
+ throw new Error(`storage(fortemi): required credential environment variable "${envName}" is not set`);
50
+ }
51
+ headers[header] = header.toLowerCase() === 'authorization' ? `Bearer ${value}` : value;
52
+ }
53
+ return headers;
54
+ }
55
+ export function validateRemoteMcpUrl(raw) {
56
+ let url;
57
+ try {
58
+ url = new URL(raw);
59
+ }
60
+ catch {
61
+ throw new Error(`storage(fortemi): invalid MCP server URL "${raw}"`);
62
+ }
63
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
64
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
65
+ throw new Error('storage(fortemi): remote MCP URLs must use HTTPS; HTTP is allowed only for loopback development');
66
+ }
67
+ return url;
68
+ }
69
+ export function unwrapMcpToolResult(result) {
70
+ if (!result || typeof result !== 'object')
71
+ return result;
72
+ const envelope = result;
73
+ if (envelope.isError) {
74
+ const detail = envelope.content
75
+ ?.filter((item) => item.type === 'text' && typeof item.text === 'string')
76
+ .map((item) => item.text)
77
+ .join('; ');
78
+ throw new Error(`storage(fortemi): MCP tool failed${detail ? `: ${detail}` : ''}`);
79
+ }
80
+ if (envelope.structuredContent !== undefined)
81
+ return envelope.structuredContent;
82
+ const text = envelope.content?.find((item) => item.type === 'text' && typeof item.text === 'string')?.text;
83
+ if (text === undefined)
84
+ return result;
85
+ try {
86
+ return JSON.parse(text);
87
+ }
88
+ catch {
89
+ return { content: text };
90
+ }
91
+ }
40
92
  export class FortemiAdapter {
41
93
  subsystem;
42
94
  mcpServer;
@@ -194,30 +246,60 @@ export class FortemiAdapter {
194
246
  * Implemented as a lazy import so tests that inject a stub never load
195
247
  * the SDK or touch the registry.
196
248
  */
197
- export const createDefaultMcpClient = async (serverName) => {
249
+ export const createDefaultMcpClient = async (serverName, registryOverride, environment = process.env) => {
198
250
  const { McpServerRegistry } = await import('../../mcp/registry.js');
199
- const registry = new McpServerRegistry();
251
+ const registry = registryOverride ?? new McpServerRegistry();
200
252
  const server = await registry.get(serverName);
201
253
  if (!server) {
202
254
  throw new Error(`storage(fortemi): MCP server "${serverName}" is not registered. ` +
203
255
  `Add it via "aiwg mcp add ${serverName} --command <cmd>" before using the fortemi backend.`);
204
256
  }
205
- if (server.type !== 'stdio') {
206
- throw new Error(`storage(fortemi): only stdio MCP servers are supported (got "${server.type}" for "${serverName}")`);
207
- }
208
- // Lazy import the SDK so tests that inject a stub don't pay the cost
257
+ // Lazy imports keep unit tests that inject a stub isolated from transports.
209
258
  const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
210
- const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
211
- const transport = new StdioClientTransport({
212
- command: server.command ?? '',
213
- args: server.args ?? [],
214
- env: server.env,
215
- });
259
+ let transport;
260
+ if (server.type === 'stdio') {
261
+ const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
262
+ transport = new StdioClientTransport({
263
+ command: server.command ?? '',
264
+ args: server.args ?? [],
265
+ env: server.env,
266
+ });
267
+ }
268
+ else {
269
+ if (!server.url) {
270
+ throw new Error(`storage(fortemi): MCP server "${serverName}" has no URL`);
271
+ }
272
+ const url = validateRemoteMcpUrl(server.url);
273
+ const headers = resolveMcpRequestHeaders(server, environment);
274
+ if (server.type === 'http') {
275
+ const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
276
+ transport = new StreamableHTTPClientTransport(url, {
277
+ requestInit: { headers },
278
+ });
279
+ }
280
+ else if (server.type === 'sse') {
281
+ const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
282
+ transport = new SSEClientTransport(url, {
283
+ requestInit: { headers },
284
+ eventSourceInit: {
285
+ fetch: async (input, init) => {
286
+ const merged = new Headers(init?.headers);
287
+ for (const [name, value] of Object.entries(headers))
288
+ merged.set(name, value);
289
+ return fetch(input, { ...init, headers: merged });
290
+ },
291
+ },
292
+ });
293
+ }
294
+ else {
295
+ throw new Error(`storage(fortemi): unsupported MCP transport "${String(server.type)}"`);
296
+ }
297
+ }
216
298
  const client = new Client({ name: 'aiwg-storage-fortemi-adapter', version: '1.0.0' }, { capabilities: {} });
217
299
  await client.connect(transport);
218
300
  return {
219
301
  async callTool(name, args) {
220
- return client.callTool({ name, arguments: args });
302
+ return unwrapMcpToolResult(await client.callTool({ name, arguments: args }));
221
303
  },
222
304
  async close() {
223
305
  await client.close();
@@ -7,6 +7,7 @@
7
7
  * test <subsystem> — round-trip read/write/list/delete through the
8
8
  * configured backend
9
9
  * migrate <subsystem> — copy entries from one backend to another
10
+ * import-corpus — ingest the local research corpus through a storage backend
10
11
  *
11
12
  * @design @.aiwg/architecture/storage-design.md (§7)
12
13
  * @issue #934
@@ -16,13 +17,13 @@
16
17
  import { randomUUID } from 'crypto';
17
18
  import { existsSync } from 'fs';
18
19
  import { mkdir, readFile, writeFile, appendFile } from 'fs/promises';
19
- import { dirname, join, resolve as resolvePath } from 'path';
20
+ import { dirname, extname, join, resolve as resolvePath } from 'path';
21
+ import { parseFrontmatter } from '../artifacts/index-builder.js';
20
22
  import { BACKEND_TYPES, FilesystemAdapter, ObsidianAdapter, LogseqAdapter, FortemiAdapter, SUBSYSTEM_KEYS, getLoadedConfig, initStorage, resolveStorage, resolveSubsystemRoot, storageConfigPath, } from './index.js';
21
23
  import { projectAiwgPath, resolveProjectAiwgDir } from '../config/project-artifacts.js';
22
- export async function main(args) {
24
+ export async function main(args, projectRoot = process.cwd()) {
23
25
  const subcommand = args[0];
24
26
  const subArgs = args.slice(1);
25
- const projectRoot = process.cwd();
26
27
  switch (subcommand) {
27
28
  case 'show':
28
29
  await handleShow(projectRoot);
@@ -36,6 +37,9 @@ export async function main(args) {
36
37
  case 'migrate':
37
38
  await handleMigrate(projectRoot, subArgs);
38
39
  break;
40
+ case 'import-corpus':
41
+ await handleImportCorpus(projectRoot, subArgs);
42
+ break;
39
43
  default:
40
44
  printUsage();
41
45
  if (subcommand) {
@@ -166,7 +170,7 @@ async function handleMigrate(projectRoot, args) {
166
170
  }
167
171
  if (source.init)
168
172
  await source.init();
169
- if (destination.init)
173
+ if (!opts.dryRun && destination.init)
170
174
  await destination.init();
171
175
  console.log(`storage migrate (${opts.dryRun ? 'DRY RUN' : 'live'})`);
172
176
  console.log(` subsystem: ${opts.subsystem}`);
@@ -184,7 +188,13 @@ async function handleMigrate(projectRoot, args) {
184
188
  let copied = 0;
185
189
  let skipped = 0;
186
190
  let errored = 0;
191
+ let unsupported = 0;
187
192
  for (const entry of entries) {
193
+ if (opts.textOnly && !isTextCorpusEntry(entry.path)) {
194
+ unsupported++;
195
+ console.log(` · ${entry.path} (non-text attachment skipped)`);
196
+ continue;
197
+ }
188
198
  if (completed.has(entry.path)) {
189
199
  skipped++;
190
200
  console.log(` ✓ ${entry.path} (already migrated, skipped)`);
@@ -202,7 +212,7 @@ async function handleMigrate(projectRoot, args) {
202
212
  console.log(` ✗ ${entry.path} (read returned null)`);
203
213
  continue;
204
214
  }
205
- await destination.write(entry.path, content);
215
+ await destination.write(entry.path, content, migrationMetadata(entry.path, content));
206
216
  await recordCompletion(migrationLogPath, entry.path);
207
217
  copied++;
208
218
  console.log(` ✓ ${entry.path}`);
@@ -217,7 +227,8 @@ async function handleMigrate(projectRoot, args) {
217
227
  if (destination.close)
218
228
  await destination.close();
219
229
  console.log('');
220
- console.log(`Summary: copied=${copied} skipped=${skipped} errored=${errored} total=${entries.length}`);
230
+ console.log(`Summary: copied=${copied} skipped=${skipped} unsupported=${unsupported} ` +
231
+ `errored=${errored} total=${entries.length}`);
221
232
  if (!opts.dryRun) {
222
233
  console.log(`Migration log: ${migrationLogPath}`);
223
234
  }
@@ -232,6 +243,7 @@ function parseMigrateArgs(args) {
232
243
  let fromFolder;
233
244
  let toFolder;
234
245
  let dryRun = false;
246
+ let textOnly = false;
235
247
  for (let i = 0; i < args.length; i++) {
236
248
  const a = args[i];
237
249
  if (a === '--from')
@@ -244,6 +256,8 @@ function parseMigrateArgs(args) {
244
256
  toFolder = args[++i];
245
257
  else if (a === '--dry-run')
246
258
  dryRun = true;
259
+ else if (a === '--text-only')
260
+ textOnly = true;
247
261
  else if (!a.startsWith('--') && !subsystem)
248
262
  subsystem = a;
249
263
  else
@@ -261,8 +275,73 @@ function parseMigrateArgs(args) {
261
275
  from: { ...parseSpec(from), ...(fromFolder ? { folder: fromFolder } : {}) },
262
276
  to: { ...parseSpec(to), ...(toFolder ? { folder: toFolder } : {}) },
263
277
  dryRun,
278
+ textOnly,
264
279
  };
265
280
  }
281
+ const TEXT_CORPUS_EXTENSIONS = new Set([
282
+ '.bib',
283
+ '.csv',
284
+ '.htm',
285
+ '.html',
286
+ '.json',
287
+ '.md',
288
+ '.ris',
289
+ '.txt',
290
+ '.xml',
291
+ '.yaml',
292
+ '.yml',
293
+ ]);
294
+ export function isTextCorpusEntry(entryPath) {
295
+ return TEXT_CORPUS_EXTENSIONS.has(extname(entryPath).toLowerCase());
296
+ }
297
+ function migrationMetadata(entryPath, content) {
298
+ const extension = extname(entryPath).toLowerCase();
299
+ const contentType = extension === '.md' ? 'text/markdown' : 'text/plain';
300
+ if (extension !== '.md')
301
+ return { contentType };
302
+ return { contentType, frontmatter: parseFrontmatter(content).data };
303
+ }
304
+ async function handleImportCorpus(projectRoot, args) {
305
+ let server = 'fortemi';
306
+ let destination;
307
+ let serverSelected = false;
308
+ let dryRun = false;
309
+ for (let index = 0; index < args.length; index++) {
310
+ const arg = args[index];
311
+ if (arg === '--server') {
312
+ server = args[++index] ?? '';
313
+ serverSelected = true;
314
+ if (!server)
315
+ throw new Error('storage import-corpus: --server requires a name');
316
+ }
317
+ else if (arg === '--to') {
318
+ destination = args[++index] ?? '';
319
+ if (!destination)
320
+ throw new Error('storage import-corpus: --to requires a backend spec');
321
+ }
322
+ else if (arg === '--dry-run') {
323
+ dryRun = true;
324
+ }
325
+ else {
326
+ throw new Error(`Unknown import-corpus flag: ${arg}`);
327
+ }
328
+ }
329
+ if (serverSelected && destination) {
330
+ throw new Error('storage import-corpus: use either --server or --to, not both');
331
+ }
332
+ await initStorage(projectRoot);
333
+ const config = await getLoadedConfig(projectRoot);
334
+ const sourceRoot = resolveSubsystemRoot('research', projectRoot, config);
335
+ await handleMigrate(projectRoot, [
336
+ 'research',
337
+ '--from',
338
+ `fs:${sourceRoot}`,
339
+ '--to',
340
+ destination ?? `fortemi:${server}`,
341
+ '--text-only',
342
+ ...(dryRun ? ['--dry-run'] : []),
343
+ ]);
344
+ }
266
345
  function parseSpec(raw) {
267
346
  const idx = raw.indexOf(':');
268
347
  if (idx === -1) {
@@ -449,11 +528,16 @@ Subcommands:
449
528
  list-backends [--json] Inventory of compiled-in adapters; --json emits structured output including tracking_issue URL for stubs
450
529
  test <subsystem> Round-trip read/write/list/delete through the configured backend
451
530
  migrate <subsystem> Copy entries from one backend to another (#955)
531
+ import-corpus Ingest local research text through a storage backend (#1508)
532
+ --to <type>:<location> Provider-neutral destination backend (default: fortemi:fortemi)
533
+ --server <name> MCP registry server name (default: fortemi)
534
+ --dry-run Preview corpus selection without connecting
452
535
  --from <type>:<location> Source spec (fs:./dir, obsidian:~/vault, logseq:./graph, fortemi:server)
453
536
  --to <type>:<location> Destination spec (same format)
454
537
  --from-folder <folder> Optional Obsidian subfolder for source
455
538
  --to-folder <folder> Optional Obsidian subfolder for destination
456
539
  --dry-run Preview operations without writing
540
+ --text-only Skip non-text attachments
457
541
 
458
542
  Subsystems: ${SUBSYSTEM_KEYS.join(', ')}
459
543
 
@@ -463,6 +547,9 @@ Examples:
463
547
  aiwg storage test activity_log
464
548
  aiwg storage migrate memory --from fs:.aiwg/memory --to obsidian:~/vault --to-folder AIWG/memory --dry-run
465
549
  aiwg storage migrate kb --from fs:.aiwg/kb --to fortemi:fortemi
550
+ aiwg storage import-corpus --dry-run
551
+ aiwg storage import-corpus --to obsidian:~/vault --dry-run
552
+ aiwg storage import-corpus --server fortemi-enterprise
466
553
 
467
554
  See @.aiwg/architecture/storage-design.md for the design.`);
468
555
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cli",
3
- "version": "2026.8.3",
3
+ "version": "2026.8.5",
4
4
  "description": "Lightweight AIWG CLI for signed, versioned web-backed resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",