valija 0.1.0 → 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.
package/dist/program.js CHANGED
@@ -21,6 +21,75 @@ var DomainError = class extends Error {
21
21
  // src/context/domain/errors.ts
22
22
  var contextErr = (code, message) => err(new DomainError(code, message));
23
23
 
24
+ // src/context/domain/services/context-pack.ts
25
+ var DEFAULT_BUDGET_TOKENS = 4e3;
26
+ var estimateTokens = (text) => Math.ceil(text.length / 4);
27
+ var estimateItemTokens = (item) => estimateTokens(
28
+ `${item.type} ${item.createdAt.toISOString().slice(0, 10)} ${item.tags.join(" ")}
29
+
30
+ ${item.content}`
31
+ );
32
+ var SECTION_TYPE_ORDER = ["decision", "preference", "progress", "fact"];
33
+ var estimatePreambleTokens = (input) => estimateTokens(
34
+ `Context pack: ${input.projectName} \u2014 ${input.items.length} items, generated ${input.generatedAt.toISOString()}`
35
+ );
36
+ function assembleContextPack(input) {
37
+ const draft = {
38
+ sections: [],
39
+ included: /* @__PURE__ */ new Set(),
40
+ budget: input.budgetTokens ?? Number.POSITIVE_INFINITY,
41
+ usedTokens: estimatePreambleTokens(input)
42
+ };
43
+ addPinned(draft, input.items);
44
+ addLatestHandoff(draft, input.items);
45
+ addTypeSections(draft, input.items);
46
+ return {
47
+ projectName: input.projectName,
48
+ generatedAt: input.generatedAt,
49
+ sections: draft.sections,
50
+ includedCount: draft.included.size,
51
+ totalCount: input.items.length,
52
+ estimatedTokens: draft.usedTokens
53
+ };
54
+ }
55
+ function addPinned(draft, items) {
56
+ const pinned = items.filter((item) => item.pinned);
57
+ if (pinned.length === 0) return;
58
+ draft.usedTokens += estimateTokens("Pinned");
59
+ const kept = [];
60
+ for (const item of pinned) {
61
+ const cost = estimateItemTokens(item);
62
+ if (kept.length > 0 && draft.usedTokens + cost > draft.budget) break;
63
+ kept.push(item);
64
+ draft.usedTokens += cost;
65
+ draft.included.add(item.id);
66
+ }
67
+ draft.sections.push({ kind: "pinned", items: kept });
68
+ }
69
+ function addLatestHandoff(draft, items) {
70
+ const handoff = items.find((item) => item.type === "handoff" && !draft.included.has(item.id));
71
+ if (handoff === void 0) return;
72
+ const cost = estimateItemTokens(handoff) + estimateTokens("Latest handoff");
73
+ if (draft.usedTokens + cost > draft.budget) return;
74
+ draft.usedTokens += cost;
75
+ draft.sections.push({ kind: "latest-handoff", items: [handoff] });
76
+ draft.included.add(handoff.id);
77
+ }
78
+ function addTypeSections(draft, items) {
79
+ for (const type of SECTION_TYPE_ORDER) {
80
+ const candidates = items.filter((item) => item.type === type && !draft.included.has(item.id));
81
+ const kept = [];
82
+ for (const item of candidates) {
83
+ const cost = estimateItemTokens(item) + (kept.length === 0 ? estimateTokens(type) : 0);
84
+ if (draft.usedTokens + cost > draft.budget) break;
85
+ kept.push(item);
86
+ draft.usedTokens += cost;
87
+ draft.included.add(item.id);
88
+ }
89
+ if (kept.length > 0) draft.sections.push({ kind: "by-type", type, items: kept });
90
+ }
91
+ }
92
+
24
93
  // src/context/domain/values/project-name.ts
25
94
  var PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
26
95
  function parseProjectName(raw) {
@@ -34,62 +103,7 @@ function parseProjectName(raw) {
34
103
  return ok(normalized);
35
104
  }
36
105
 
37
- // src/context/application/export-pack.ts
38
- var ExportPack = class {
39
- constructor(sessions, getContextPack) {
40
- this.sessions = sessions;
41
- this.getContextPack = getContextPack;
42
- }
43
- sessions;
44
- getContextPack;
45
- execute(project, format) {
46
- if (format === "md") {
47
- const pack = this.getContextPack.execute({ project, budgetTokens: Number.MAX_SAFE_INTEGER });
48
- return pack.ok ? ok(pack.value.markdown) : pack;
49
- }
50
- const name = parseProjectName(project);
51
- if (!name.ok) return name;
52
- const session = this.sessions.open();
53
- if (!session.ok) return session;
54
- try {
55
- const found = session.value.projects.findByName(name.value);
56
- if (found === null) {
57
- return contextErr("PROJECT_NOT_FOUND", `No project named "${name.value}".`);
58
- }
59
- const items = session.value.items.findByProject(found.id).map((item) => ({
60
- id: item.id,
61
- type: item.type,
62
- content: item.content,
63
- tags: item.tags,
64
- pinned: item.pinned,
65
- source: item.source ?? null,
66
- createdAt: item.createdAt.toISOString()
67
- }));
68
- return ok(JSON.stringify({ project: name.value, items }, null, 2));
69
- } finally {
70
- session.value.close();
71
- }
72
- }
73
- };
74
-
75
- // src/context/application/get-context-pack.ts
76
- var DEFAULT_BUDGET_TOKENS = 4e3;
77
- var estimateTokens = (text) => Math.ceil(text.length / 4);
78
- var SECTION_ORDER = ["decision", "preference", "progress", "fact"];
79
- var SECTION_LABELS = {
80
- decision: "Decisions",
81
- preference: "Preferences",
82
- progress: "Progress",
83
- fact: "Facts"
84
- };
85
- var renderItem = (item) => {
86
- const date = item.createdAt.toISOString().slice(0, 10);
87
- const tags = item.tags.length > 0 ? ` \xB7 ${item.tags.map((t) => `#${t}`).join(" ")}` : "";
88
- return `### ${item.type} \xB7 ${date}${tags}
89
-
90
- ${item.content}
91
- `;
92
- };
106
+ // src/context/application/use-cases/get-context-pack.use-case.ts
93
107
  var GetContextPack = class {
94
108
  constructor(sessions, clock) {
95
109
  this.sessions = sessions;
@@ -100,107 +114,62 @@ var GetContextPack = class {
100
114
  execute(input) {
101
115
  const name = parseProjectName(input.project);
102
116
  if (!name.ok) return name;
103
- const budget = input.budgetTokens ?? DEFAULT_BUDGET_TOKENS;
104
- const session = this.sessions.open();
105
- if (!session.ok) return session;
106
- try {
107
- const project = session.value.projects.findByName(name.value);
117
+ return this.sessions.withSession((session) => {
118
+ const project = session.projects.findByName(name.value);
108
119
  if (project === null) {
109
120
  return contextErr("PROJECT_NOT_FOUND", `No project named "${name.value}".`);
110
121
  }
111
- const all = session.value.items.findByProject(project.id);
112
- const included = /* @__PURE__ */ new Set();
113
- const parts = [];
114
- let used = 0;
115
- const header = `# Context pack: ${name.value}
116
-
117
- > ${all.length} items in vault \xB7 generated ${this.clock.now().toISOString()}
118
- `;
119
- used += estimateTokens(header);
120
- const pinned = all.filter((i) => i.pinned);
121
- const pinnedKept = [];
122
- for (const item of pinned) {
123
- const cost = estimateTokens(renderItem(item));
124
- if (pinnedKept.length > 0 && used + cost > budget) break;
125
- pinnedKept.push(item);
126
- used += cost;
127
- }
128
- if (pinnedKept.length > 0) {
129
- parts.push("\n## Pinned\n");
130
- for (const item of pinnedKept) {
131
- parts.push(renderItem(item));
132
- included.add(item.id);
133
- }
134
- }
135
- const handoff = all.find((i) => i.type === "handoff" && !included.has(i.id));
136
- if (handoff) {
137
- const rendered = renderItem(handoff);
138
- const cost = estimateTokens(rendered);
139
- if (used + cost <= budget) {
140
- parts.push("\n## Latest handoff\n", rendered);
141
- included.add(handoff.id);
142
- used += cost;
143
- }
144
- }
145
- for (const type of SECTION_ORDER) {
146
- const items = all.filter((i) => i.type === type && !included.has(i.id));
147
- let sectionOpened = false;
148
- for (const item of items) {
149
- const rendered = renderItem(item);
150
- const sectionHeader = sectionOpened ? "" : `
151
- ## ${SECTION_LABELS[type]}
152
- `;
153
- const cost = estimateTokens(sectionHeader + rendered);
154
- if (used + cost > budget) break;
155
- if (!sectionOpened) {
156
- parts.push(sectionHeader);
157
- sectionOpened = true;
158
- }
159
- parts.push(rendered);
160
- included.add(item.id);
161
- used += cost;
162
- }
163
- }
164
- const markdown = header + parts.join("\n");
165
- return ok({
166
- markdown,
167
- includedCount: included.size,
168
- totalCount: all.length,
169
- estimatedTokens: estimateTokens(markdown)
170
- });
171
- } finally {
172
- session.value.close();
173
- }
174
- }
175
- };
176
-
177
- // src/context/application/list-projects.ts
178
- var ListProjects = class {
179
- constructor(sessions) {
180
- this.sessions = sessions;
181
- }
182
- sessions;
183
- execute() {
184
- const session = this.sessions.open();
185
- if (!session.ok) return session;
186
- try {
187
- const entries = session.value.projects.list().map((summary) => ({
188
- name: summary.project.name,
189
- ...summary.project.description === void 0 ? {} : { description: summary.project.description },
190
- itemCount: summary.itemCount,
191
- lastActivityAt: summary.lastActivityAt?.toISOString() ?? null
192
- }));
193
- return ok(entries);
194
- } finally {
195
- session.value.close();
196
- }
122
+ return ok(
123
+ assembleContextPack({
124
+ projectName: name.value,
125
+ items: session.items.findByProject(project.id),
126
+ generatedAt: this.clock.now(),
127
+ budgetTokens: input.budgetTokens ?? DEFAULT_BUDGET_TOKENS
128
+ })
129
+ );
130
+ });
197
131
  }
198
132
  };
199
133
 
200
134
  // src/context/domain/entities/context-item.ts
135
+ import { createHash } from "crypto";
136
+ function createContextItem(input) {
137
+ return {
138
+ id: input.id,
139
+ projectId: input.projectId,
140
+ type: input.type,
141
+ content: input.content,
142
+ tags: input.tags,
143
+ pinned: input.pinned,
144
+ ...input.source === void 0 ? {} : { source: input.source },
145
+ archived: false,
146
+ createdAt: input.now,
147
+ updatedAt: input.now
148
+ };
149
+ }
150
+ function importedItemId(projectId, source, conversationId, chunkIndex) {
151
+ const digest = createHash("sha256").update(`${projectId} ${source} ${conversationId} ${chunkIndex}`).digest("hex");
152
+ return `imp-${digest.slice(0, 32)}`;
153
+ }
154
+ function createImportedContextItem(input) {
155
+ return {
156
+ id: importedItemId(input.projectId, input.source, input.conversationId, input.chunkIndex),
157
+ projectId: input.projectId,
158
+ type: "imported",
159
+ content: input.content,
160
+ tags: input.tags,
161
+ pinned: false,
162
+ source: `${input.source}-import`,
163
+ archived: false,
164
+ createdAt: input.createdAt,
165
+ updatedAt: input.now
166
+ };
167
+ }
168
+
169
+ // src/context/domain/values/content.ts
201
170
  var MAX_CONTENT_BYTES = 32 * 1024;
202
- function validateContent(content) {
203
- const trimmed = content.trim();
171
+ function parseContent(raw) {
172
+ const trimmed = raw.trim();
204
173
  if (trimmed.length === 0) {
205
174
  return contextErr("CONTENT_EMPTY", "Content must not be empty.");
206
175
  }
@@ -214,18 +183,6 @@ function validateContent(content) {
214
183
  return ok(trimmed);
215
184
  }
216
185
 
217
- // src/context/domain/values/item-type.ts
218
- var ITEM_TYPES = ["decision", "progress", "preference", "fact", "handoff"];
219
- function parseItemType(raw) {
220
- if (ITEM_TYPES.includes(raw)) {
221
- return ok(raw);
222
- }
223
- return contextErr(
224
- "INVALID_ITEM_TYPE",
225
- `Item type must be one of ${ITEM_TYPES.join(", ")}. Got: "${raw}"`
226
- );
227
- }
228
-
229
186
  // src/context/domain/values/tag.ts
230
187
  var PATTERN2 = /^[a-z0-9][a-z0-9-]{0,31}$/;
231
188
  var MAX_TAGS = 10;
@@ -249,7 +206,103 @@ function parseTags(raw) {
249
206
  return ok(tags);
250
207
  }
251
208
 
252
- // src/context/application/save-context.ts
209
+ // src/context/application/use-cases/import-items.use-case.ts
210
+ var ImportItems = class {
211
+ constructor(sessions, clock, idGen) {
212
+ this.sessions = sessions;
213
+ this.clock = clock;
214
+ this.idGen = idGen;
215
+ }
216
+ sessions;
217
+ clock;
218
+ idGen;
219
+ execute(input) {
220
+ const name = parseProjectName(input.projectName);
221
+ if (!name.ok) return name;
222
+ return this.sessions.withSession((session) => {
223
+ const { project, created } = this.findOrCreateProject(session, name.value);
224
+ const failures = [];
225
+ let imported = 0;
226
+ for (const draft of input.items) {
227
+ const saved = this.saveDraft(session, project.id, draft);
228
+ if (saved.ok) imported += 1;
229
+ else failures.push({ conversationId: draft.conversationId, reason: saved.error.message });
230
+ }
231
+ return ok({ projectCreated: created, imported, failed: failures.length, failures });
232
+ });
233
+ }
234
+ /** Re-validate tags and content at the vault boundary — defense in depth, even though the renderer aims well under the limit. */
235
+ saveDraft(session, projectId, draft) {
236
+ const tags = parseTags(draft.tags);
237
+ if (!tags.ok) return tags;
238
+ const content = parseContent(draft.content);
239
+ if (!content.ok) return content;
240
+ session.items.save(
241
+ createImportedContextItem({
242
+ projectId,
243
+ source: draft.source,
244
+ conversationId: draft.conversationId,
245
+ chunkIndex: draft.chunkIndex,
246
+ content: content.value,
247
+ tags: tags.value,
248
+ createdAt: draft.createdAt,
249
+ now: this.clock.now()
250
+ })
251
+ );
252
+ return ok(void 0);
253
+ }
254
+ findOrCreateProject(session, name) {
255
+ const existing = session.projects.findByName(name);
256
+ if (existing !== null) return { project: existing, created: false };
257
+ const now = this.clock.now();
258
+ const project = { id: this.idGen.next(), name, createdAt: now, updatedAt: now };
259
+ session.projects.save(project);
260
+ return { project, created: true };
261
+ }
262
+ };
263
+
264
+ // src/context/application/use-cases/list-projects.use-case.ts
265
+ var ListProjects = class {
266
+ constructor(sessions) {
267
+ this.sessions = sessions;
268
+ }
269
+ sessions;
270
+ execute() {
271
+ return this.sessions.withSession((session) => {
272
+ const entries = session.projects.list().map((summary) => ({
273
+ name: summary.project.name,
274
+ ...summary.project.description === void 0 ? {} : { description: summary.project.description },
275
+ itemCount: summary.itemCount,
276
+ lastActivityAt: summary.lastActivityAt?.toISOString() ?? null
277
+ }));
278
+ return ok(entries);
279
+ });
280
+ }
281
+ };
282
+
283
+ // src/context/domain/values/item-type.ts
284
+ var ITEM_TYPES = ["decision", "progress", "preference", "fact", "handoff"];
285
+ function parseItemType(raw) {
286
+ if (ITEM_TYPES.includes(raw)) {
287
+ return ok(raw);
288
+ }
289
+ return contextErr(
290
+ "INVALID_ITEM_TYPE",
291
+ `Item type must be one of ${ITEM_TYPES.join(", ")}. Got: "${raw}"`
292
+ );
293
+ }
294
+ var STORABLE_ITEM_TYPES = [...ITEM_TYPES, "imported"];
295
+ function parseStorableItemType(raw) {
296
+ if (STORABLE_ITEM_TYPES.includes(raw)) {
297
+ return ok(raw);
298
+ }
299
+ return contextErr(
300
+ "INVALID_ITEM_TYPE",
301
+ `Item type must be one of ${STORABLE_ITEM_TYPES.join(", ")}. Got: "${raw}"`
302
+ );
303
+ }
304
+
305
+ // src/context/application/use-cases/save-context.use-case.ts
253
306
  var SaveContext = class {
254
307
  constructor(sessions, clock, idGen) {
255
308
  this.sessions = sessions;
@@ -260,129 +313,134 @@ var SaveContext = class {
260
313
  clock;
261
314
  idGen;
262
315
  execute(input) {
316
+ const validated = this.parseInput(input);
317
+ if (!validated.ok) return validated;
318
+ return this.sessions.withSession((session) => {
319
+ const { project, created } = this.findOrCreateProject(session, validated.value.name);
320
+ const item = createContextItem({
321
+ id: this.idGen.next(),
322
+ projectId: project.id,
323
+ type: validated.value.type,
324
+ content: validated.value.content,
325
+ tags: validated.value.tags,
326
+ pinned: validated.value.pinned,
327
+ ...validated.value.source === void 0 ? {} : { source: validated.value.source },
328
+ now: this.clock.now()
329
+ });
330
+ session.items.save(item);
331
+ return ok({
332
+ itemId: item.id,
333
+ project: validated.value.name,
334
+ type: validated.value.type,
335
+ projectCreated: created
336
+ });
337
+ });
338
+ }
339
+ /** Everything is parsed before a session is opened: bad input never touches the vault. */
340
+ parseInput(input) {
263
341
  const name = parseProjectName(input.project);
264
342
  if (!name.ok) return name;
265
343
  const type = parseItemType(input.type ?? "fact");
266
344
  if (!type.ok) return type;
267
345
  const tags = parseTags(input.tags ?? []);
268
346
  if (!tags.ok) return tags;
269
- const content = validateContent(input.content);
347
+ const content = parseContent(input.content);
270
348
  if (!content.ok) return content;
271
- const session = this.sessions.open();
272
- if (!session.ok) return session;
273
- try {
274
- const now = this.clock.now();
275
- let project = session.value.projects.findByName(name.value);
276
- const projectCreated = project === null;
277
- if (project === null) {
278
- project = {
279
- id: this.idGen.next(),
280
- name: name.value,
281
- createdAt: now,
282
- updatedAt: now
283
- };
284
- session.value.projects.save(project);
285
- }
286
- const itemId = this.idGen.next();
287
- session.value.items.save({
288
- id: itemId,
289
- projectId: project.id,
290
- type: type.value,
291
- content: content.value,
292
- tags: tags.value,
293
- pinned: input.pinned ?? false,
294
- ...input.source === void 0 ? {} : { source: input.source },
295
- archived: false,
296
- createdAt: now,
297
- updatedAt: now
298
- });
299
- return ok({ itemId, project: name.value, type: type.value, projectCreated });
300
- } finally {
301
- session.value.close();
302
- }
349
+ return ok({
350
+ name: name.value,
351
+ type: type.value,
352
+ tags: tags.value,
353
+ content: content.value,
354
+ pinned: input.pinned ?? false,
355
+ ...input.source === void 0 ? {} : { source: input.source }
356
+ });
357
+ }
358
+ findOrCreateProject(session, name) {
359
+ const existing = session.projects.findByName(name);
360
+ if (existing !== null) return { project: existing, created: false };
361
+ const now = this.clock.now();
362
+ const project = { id: this.idGen.next(), name, createdAt: now, updatedAt: now };
363
+ session.projects.save(project);
364
+ return { project, created: true };
303
365
  }
304
366
  };
305
367
 
306
- // src/context/application/search-context.ts
368
+ // src/context/application/dto/context-item-view.ts
369
+ var toContextItemView = (item, projectName) => ({
370
+ id: item.id,
371
+ project: projectName,
372
+ type: item.type,
373
+ content: item.content,
374
+ tags: [...item.tags],
375
+ pinned: item.pinned,
376
+ ...item.source === void 0 ? {} : { source: item.source },
377
+ createdAt: item.createdAt.toISOString()
378
+ });
379
+
380
+ // src/context/application/use-cases/search-context.use-case.ts
381
+ var MAX_LIMIT = 100;
382
+ var DEFAULT_LIMIT = 20;
307
383
  var SearchContext = class {
308
384
  constructor(sessions) {
309
385
  this.sessions = sessions;
310
386
  }
311
387
  sessions;
312
388
  execute(input) {
313
- const session = this.sessions.open();
314
- if (!session.ok) return session;
315
- try {
316
- let projectId;
317
- if (input.project !== void 0) {
318
- const name = parseProjectName(input.project);
319
- if (!name.ok) return name;
320
- const project = session.value.projects.findByName(name.value);
321
- if (project === null) {
322
- return contextErr("PROJECT_NOT_FOUND", `No project named "${name.value}".`);
323
- }
324
- projectId = project.id;
325
- }
326
- const limit = Math.min(Math.max(input.limit ?? 20, 1), 100);
327
- const hits = session.value.items.search(input.query, projectId, limit).map((result) => ({
328
- id: result.item.id,
329
- project: result.projectName,
330
- type: result.item.type,
331
- content: result.item.content,
332
- tags: [...result.item.tags],
333
- pinned: result.item.pinned,
334
- createdAt: result.item.createdAt.toISOString()
335
- }));
389
+ return this.sessions.withSession((session) => {
390
+ const scope = this.resolveProjectScope(session, input.project);
391
+ if (!scope.ok) return scope;
392
+ const limit = Math.min(Math.max(input.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
393
+ const hits = session.items.search(input.query, scope.value, limit).map((result) => toContextItemView(result.item, result.projectName));
336
394
  return ok(hits);
337
- } finally {
338
- session.value.close();
395
+ });
396
+ }
397
+ /** No project given → search everywhere; otherwise the project must exist. */
398
+ resolveProjectScope(session, project) {
399
+ if (project === void 0) return ok(void 0);
400
+ const name = parseProjectName(project);
401
+ if (!name.ok) return name;
402
+ const found = session.projects.findByName(name.value);
403
+ if (found === null) {
404
+ return contextErr("PROJECT_NOT_FOUND", `No project named "${name.value}".`);
339
405
  }
406
+ return ok(found.id);
340
407
  }
341
408
  };
342
409
 
343
- // src/context/application/show-project.ts
410
+ // src/context/application/use-cases/show-project.use-case.ts
344
411
  var ShowProject = class {
345
412
  constructor(sessions) {
346
413
  this.sessions = sessions;
347
414
  }
348
415
  sessions;
349
- execute(project, type) {
350
- const name = parseProjectName(project);
351
- if (!name.ok) return name;
352
- let typeValue;
353
- if (type !== void 0) {
354
- const parsed = parseItemType(type);
355
- if (!parsed.ok) return parsed;
356
- typeValue = parsed.value;
357
- }
358
- const session = this.sessions.open();
359
- if (!session.ok) return session;
360
- try {
361
- const found = session.value.projects.findByName(name.value);
416
+ execute(input) {
417
+ const filters = this.parseFilters(input);
418
+ if (!filters.ok) return filters;
419
+ return this.sessions.withSession((session) => {
420
+ const found = session.projects.findByName(filters.value.name);
362
421
  if (found === null) {
363
- return contextErr("PROJECT_NOT_FOUND", `No project named "${name.value}".`);
422
+ return contextErr("PROJECT_NOT_FOUND", `No project named "${filters.value.name}".`);
364
423
  }
365
- const items = session.value.items.findByProject(
424
+ const items = session.items.findByProject(
366
425
  found.id,
367
- typeValue === void 0 ? {} : { type: typeValue }
368
- );
369
- return ok(
370
- items.map((item) => ({
371
- id: item.id,
372
- project: name.value,
373
- type: item.type,
374
- content: item.content,
375
- tags: [...item.tags],
376
- pinned: item.pinned,
377
- createdAt: item.createdAt.toISOString()
378
- }))
426
+ filters.value.type === void 0 ? {} : { type: filters.value.type }
379
427
  );
380
- } finally {
381
- session.value.close();
382
- }
428
+ return ok(items.map((item) => toContextItemView(item, filters.value.name)));
429
+ });
430
+ }
431
+ parseFilters(input) {
432
+ const name = parseProjectName(input.project);
433
+ if (!name.ok) return name;
434
+ if (input.type === void 0) return ok({ name: name.value });
435
+ const parsed = parseStorableItemType(input.type);
436
+ if (!parsed.ok) return parsed;
437
+ return ok({ name: name.value, type: parsed.value });
383
438
  }
384
439
  };
385
440
 
441
+ // src/shared/infra/migrations.ts
442
+ import { copyFileSync, rmSync } from "fs";
443
+
386
444
  // src/shared/infra/migrations/001-init.ts
387
445
  var MIGRATION_001 = `
388
446
  CREATE TABLE projects (
@@ -433,24 +491,92 @@ CREATE TRIGGER items_au AFTER UPDATE ON context_items BEGIN
433
491
  END;
434
492
  `;
435
493
 
494
+ // src/shared/infra/migrations/002-imported-type.ts
495
+ var MIGRATION_002 = `
496
+ DROP TRIGGER items_ai;
497
+ DROP TRIGGER items_ad;
498
+ DROP TRIGGER items_au;
499
+
500
+ CREATE TABLE context_items_new (
501
+ id TEXT PRIMARY KEY,
502
+ project_id TEXT NOT NULL REFERENCES projects(id),
503
+ type TEXT NOT NULL CHECK (type IN ('decision','progress','preference','fact','handoff','imported')),
504
+ content TEXT NOT NULL,
505
+ tags TEXT NOT NULL DEFAULT '[]',
506
+ pinned INTEGER NOT NULL DEFAULT 0,
507
+ source TEXT,
508
+ archived INTEGER NOT NULL DEFAULT 0,
509
+ created_at TEXT NOT NULL,
510
+ updated_at TEXT NOT NULL
511
+ );
512
+
513
+ INSERT INTO context_items_new
514
+ (id, project_id, type, content, tags, pinned, source, archived, created_at, updated_at)
515
+ SELECT
516
+ id, project_id, type, content, tags, pinned, source, archived, created_at, updated_at
517
+ FROM context_items;
518
+
519
+ DROP TABLE context_items;
520
+ ALTER TABLE context_items_new RENAME TO context_items;
521
+
522
+ CREATE INDEX idx_items_project ON context_items(project_id, created_at DESC);
523
+
524
+ CREATE TRIGGER items_ai AFTER INSERT ON context_items BEGIN
525
+ INSERT INTO context_items_fts(rowid, content, tags)
526
+ VALUES (new.rowid, new.content, new.tags);
527
+ END;
528
+
529
+ CREATE TRIGGER items_ad AFTER DELETE ON context_items BEGIN
530
+ INSERT INTO context_items_fts(context_items_fts, rowid, content, tags)
531
+ VALUES ('delete', old.rowid, old.content, old.tags);
532
+ END;
533
+
534
+ CREATE TRIGGER items_au AFTER UPDATE ON context_items BEGIN
535
+ INSERT INTO context_items_fts(context_items_fts, rowid, content, tags)
536
+ VALUES ('delete', old.rowid, old.content, old.tags);
537
+ INSERT INTO context_items_fts(rowid, content, tags)
538
+ VALUES (new.rowid, new.content, new.tags);
539
+ END;
540
+
541
+ INSERT INTO context_items_fts(context_items_fts) VALUES('rebuild');
542
+ `;
543
+
436
544
  // src/shared/infra/migrations.ts
437
545
  var MIGRATIONS = [
438
- { version: 1, sql: MIGRATION_001 }
546
+ { version: 1, sql: MIGRATION_001 },
547
+ { version: 2, sql: MIGRATION_002, backup: true }
439
548
  ];
440
- function migrate(db) {
549
+ function migrate(db, dbPath) {
441
550
  db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
442
- const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
443
- const current = row ? Number(row.value) : 0;
551
+ const current = schemaVersion(db);
444
552
  for (const migration of MIGRATIONS) {
445
553
  if (migration.version <= current) continue;
446
- db.transaction(() => {
447
- db.exec(migration.sql);
448
- db.prepare(
449
- "INSERT INTO meta (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
450
- ).run(String(migration.version));
451
- })();
554
+ applyMigration(db, migration, current, dbPath);
452
555
  }
453
556
  }
557
+ function applyMigration(db, migration, current, dbPath) {
558
+ const backupPath = migration.backup === true && current >= 1 && dbPath !== void 0 ? backupCiphertext(db, dbPath, migration.version) : null;
559
+ db.transaction(() => {
560
+ db.exec(migration.sql);
561
+ setSchemaVersion(db, migration.version);
562
+ })();
563
+ if (backupPath !== null) rmSync(backupPath, { force: true });
564
+ }
565
+ function backupCiphertext(db, dbPath, version) {
566
+ db.pragma("wal_checkpoint(TRUNCATE)");
567
+ const backupPath = `${dbPath}.pre-${String(version).padStart(3, "0")}.bak`;
568
+ copyFileSync(dbPath, backupPath);
569
+ return backupPath;
570
+ }
571
+ function setSchemaVersion(db, version) {
572
+ db.prepare(
573
+ "INSERT INTO meta (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
574
+ ).run(String(version));
575
+ }
576
+ function schemaVersion(db) {
577
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
578
+ return row ? Number(row.value) : 0;
579
+ }
454
580
 
455
581
  // src/shared/infra/sqlite.ts
456
582
  import { mkdirSync } from "fs";
@@ -672,23 +798,44 @@ var SqliteProjectRepository = class {
672
798
  }
673
799
  };
674
800
 
675
- // src/context/infra/session-factory.ts
801
+ // src/context/infra/vault-sessions.ts
676
802
  var LOCKED_MESSAGE = 'Vault is locked. Ask the user to run "valija unlock" in a terminal.';
677
- var SqliteVaultSessionFactory = class {
803
+ function runWithSession(open, action) {
804
+ const session = open();
805
+ if (!session.ok) return session;
806
+ try {
807
+ return action(session.value);
808
+ } finally {
809
+ session.value.close();
810
+ }
811
+ }
812
+ var SqliteVaultSessions = class {
678
813
  constructor(paths, keychain) {
679
814
  this.paths = paths;
680
815
  this.keychain = keychain;
681
816
  }
682
817
  paths;
683
818
  keychain;
819
+ withSession(action) {
820
+ return runWithSession(() => this.open(), action);
821
+ }
684
822
  open() {
685
823
  const header = readVaultHeader(this.paths.header);
686
824
  if (!header.ok) return header;
687
- const keyHex = this.keychain.getKey(header.value.vaultId);
825
+ const keyHex = this.requireKey(header.value.vaultId);
826
+ if (!keyHex.ok) return keyHex;
827
+ return this.openRepositories(header.value.vaultId, keyHex.value);
828
+ }
829
+ /** The session key must be present in the OS keychain — otherwise the vault is locked. */
830
+ requireKey(vaultId) {
831
+ const keyHex = this.keychain.getKey(vaultId);
688
832
  if (keyHex === null) return vaultErr("VAULT_LOCKED", LOCKED_MESSAGE);
833
+ return ok(keyHex);
834
+ }
835
+ openRepositories(vaultId, keyHex) {
689
836
  try {
690
837
  const db = openVaultDb(this.paths.db, keyHex);
691
- migrate(db);
838
+ migrate(db, this.paths.db);
692
839
  return ok({
693
840
  projects: new SqliteProjectRepository(db),
694
841
  items: new SqliteContextItemRepository(db),
@@ -696,7 +843,7 @@ var SqliteVaultSessionFactory = class {
696
843
  });
697
844
  } catch (error) {
698
845
  if (isWrongKeyError(error)) {
699
- this.keychain.deleteKey(header.value.vaultId);
846
+ this.keychain.deleteKey(vaultId);
700
847
  return vaultErr("VAULT_LOCKED", LOCKED_MESSAGE);
701
848
  }
702
849
  return vaultErr("STORAGE_ERROR", `Could not open vault: ${error.message}`);
@@ -704,6 +851,607 @@ var SqliteVaultSessionFactory = class {
704
851
  }
705
852
  };
706
853
 
854
+ // src/importers/domain/errors.ts
855
+ var importerErr = (code, message) => err(new DomainError(code, message));
856
+
857
+ // src/importers/domain/services/chunk-render.ts
858
+ var DEFAULT_BYTE_BUDGET = 28 * 1024;
859
+ var HEADER_RESERVE = 768;
860
+ var SEPARATOR = "\n\n";
861
+ var MAX_TITLE_CHARS = 120;
862
+ var encoder = new TextEncoder();
863
+ var byteLength = (text) => encoder.encode(text).length;
864
+ var ROLE_LABEL = { user: "User", assistant: "Assistant", system: "System" };
865
+ var SOURCE_LABEL = {
866
+ chatgpt: "ChatGPT",
867
+ claude: "Claude",
868
+ generic: "Generic"
869
+ };
870
+ var renderMessage = (message) => `**${ROLE_LABEL[message.role]}:** ${message.content}`;
871
+ function hardSplit(text, limit) {
872
+ const pieces = [];
873
+ let buffer = "";
874
+ let bufferBytes = 0;
875
+ for (const char of text) {
876
+ const charBytes = byteLength(char);
877
+ if (bufferBytes + charBytes > limit && buffer.length > 0) {
878
+ pieces.push(buffer);
879
+ buffer = "";
880
+ bufferBytes = 0;
881
+ }
882
+ buffer += char;
883
+ bufferBytes += charBytes;
884
+ }
885
+ if (buffer.length > 0) pieces.push(buffer);
886
+ return pieces;
887
+ }
888
+ function packBodies(messages, limit) {
889
+ const bodies = [];
890
+ let current = [];
891
+ let currentBytes = 0;
892
+ const flush = () => {
893
+ if (current.length > 0) {
894
+ bodies.push(current.join(SEPARATOR));
895
+ current = [];
896
+ currentBytes = 0;
897
+ }
898
+ };
899
+ for (const message of messages) {
900
+ const block = renderMessage(message);
901
+ const blockBytes = byteLength(block);
902
+ if (blockBytes > limit) {
903
+ flush();
904
+ for (const piece of hardSplit(block, limit)) bodies.push(piece);
905
+ continue;
906
+ }
907
+ const cost = (current.length > 0 ? byteLength(SEPARATOR) : 0) + blockBytes;
908
+ if (currentBytes + cost > limit) flush();
909
+ current.push(block);
910
+ currentBytes += current.length > 1 ? byteLength(SEPARATOR) + blockBytes : blockBytes;
911
+ }
912
+ flush();
913
+ return bodies;
914
+ }
915
+ function provenanceHeader(conversation, source, part, total) {
916
+ const title = conversation.title.length > MAX_TITLE_CHARS ? `${conversation.title.slice(0, MAX_TITLE_CHARS - 3)}...` : conversation.title;
917
+ const date = conversation.createdAt.toISOString().slice(0, 10);
918
+ return `> Imported from ${SOURCE_LABEL[source]} \xB7 "${title}" \xB7 ${date} \xB7 part ${part}/${total}`;
919
+ }
920
+ function renderConversationChunks(conversation, source, byteBudget = DEFAULT_BYTE_BUDGET) {
921
+ const limit = byteBudget - HEADER_RESERVE;
922
+ const bodies = packBodies(conversation.messages, limit);
923
+ const total = bodies.length;
924
+ return bodies.map(
925
+ (body, index) => `${provenanceHeader(conversation, source, index + 1, total)}
926
+
927
+ ${body}`
928
+ );
929
+ }
930
+
931
+ // src/importers/domain/services/selection.ts
932
+ function parsePickSpec(spec, count) {
933
+ const tokens = spec.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
934
+ if (tokens.length === 0) {
935
+ return importerErr("INVALID_SELECTION", "--pick is empty.");
936
+ }
937
+ const indices = /* @__PURE__ */ new Set();
938
+ for (const token of tokens) {
939
+ const range = token.match(/^(\d+)-(\d+)$/);
940
+ const single = token.match(/^(\d+)$/);
941
+ if (range) {
942
+ const start = Number(range[1]);
943
+ const end = Number(range[2]);
944
+ if (start < 1 || end > count || start > end) {
945
+ return importerErr(
946
+ "INVALID_SELECTION",
947
+ `--pick range "${token}" is out of bounds (valid: 1-${count}).`
948
+ );
949
+ }
950
+ for (let i = start; i <= end; i++) indices.add(i - 1);
951
+ } else if (single) {
952
+ const index = Number(single[1]);
953
+ if (index < 1 || index > count) {
954
+ return importerErr(
955
+ "INVALID_SELECTION",
956
+ `--pick index "${token}" is out of bounds (valid: 1-${count}).`
957
+ );
958
+ }
959
+ indices.add(index - 1);
960
+ } else {
961
+ return importerErr("INVALID_SELECTION", `--pick token "${token}" is not a number or range.`);
962
+ }
963
+ }
964
+ return ok([...indices].sort((a, b) => a - b));
965
+ }
966
+ function selectConversations(conversations, filters) {
967
+ let selected = [...conversations];
968
+ if (filters.pick !== void 0) {
969
+ const picked = parsePickSpec(filters.pick, conversations.length);
970
+ if (!picked.ok) return picked;
971
+ selected = picked.value.map((index) => conversations[index]).filter((conversation) => conversation !== void 0);
972
+ }
973
+ if (filters.since !== void 0) {
974
+ const since = new Date(filters.since);
975
+ if (Number.isNaN(since.getTime())) {
976
+ return importerErr(
977
+ "INVALID_SELECTION",
978
+ `--since "${filters.since}" is not a valid date (use YYYY-MM-DD).`
979
+ );
980
+ }
981
+ selected = selected.filter(
982
+ (conversation) => conversation.createdAt.getTime() >= since.getTime()
983
+ );
984
+ }
985
+ if (filters.query !== void 0) {
986
+ const needle = filters.query.toLowerCase();
987
+ selected = selected.filter((conversation) => conversation.title.toLowerCase().includes(needle));
988
+ }
989
+ if (selected.length === 0) {
990
+ return importerErr("NO_CONVERSATIONS_SELECTED", "No conversations matched the given filters.");
991
+ }
992
+ return ok(selected);
993
+ }
994
+
995
+ // src/importers/application/use-cases/import-conversations.use-case.ts
996
+ var ImportConversations = class {
997
+ constructor(reader2, registry, importItems, clock) {
998
+ this.reader = reader2;
999
+ this.registry = registry;
1000
+ this.importItems = importItems;
1001
+ this.clock = clock;
1002
+ }
1003
+ reader;
1004
+ registry;
1005
+ importItems;
1006
+ clock;
1007
+ execute(input) {
1008
+ const docs = this.reader.read(input.filePath);
1009
+ if (!docs.ok) return docs;
1010
+ const resolved = this.resolveParser(docs.value, input.from);
1011
+ if (!resolved.ok) return resolved;
1012
+ const parsed = resolved.value.parser.parse(resolved.value.doc);
1013
+ if (!parsed.ok) return parsed;
1014
+ const source = resolved.value.parser.source;
1015
+ const conversations = [...parsed.value.conversations].sort(
1016
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
1017
+ );
1018
+ const parseFailures = parsed.value.failures.map((failure) => ({
1019
+ conversation: failure.title,
1020
+ reason: failure.reason
1021
+ }));
1022
+ const mode = this.resolveMode(input);
1023
+ if (mode === "list") {
1024
+ return ok(
1025
+ this.summary(mode, source, input, parseFailures, {
1026
+ listing: this.toListing(conversations, source)
1027
+ })
1028
+ );
1029
+ }
1030
+ const selected = selectConversations(conversations, {
1031
+ ...input.all === void 0 ? {} : { all: input.all },
1032
+ ...input.pick === void 0 ? {} : { pick: input.pick },
1033
+ ...input.query === void 0 ? {} : { query: input.query },
1034
+ ...input.since === void 0 ? {} : { since: input.since }
1035
+ });
1036
+ if (!selected.ok) return selected;
1037
+ const { items, skipped, contributed } = this.buildItems(selected.value, source);
1038
+ if (mode === "dry-run") {
1039
+ return ok(
1040
+ this.summary(mode, source, input, parseFailures, {
1041
+ imported: items.length,
1042
+ conversations: contributed,
1043
+ skipped
1044
+ })
1045
+ );
1046
+ }
1047
+ const written = this.importItems.execute({ projectName: input.projectName ?? "", items });
1048
+ if (!written.ok) return written;
1049
+ const importFailures = written.value.failures.map((failure) => ({
1050
+ conversation: failure.conversationId,
1051
+ reason: failure.reason
1052
+ }));
1053
+ return ok(
1054
+ this.summary(mode, source, input, [...parseFailures, ...importFailures], {
1055
+ imported: written.value.imported,
1056
+ conversations: contributed,
1057
+ skipped
1058
+ })
1059
+ );
1060
+ }
1061
+ /** With `--from`, use that parser and surface its parse error verbatim; otherwise first `detect()` wins. */
1062
+ resolveParser(docs, from) {
1063
+ if (from !== void 0) {
1064
+ const parser = this.registry.forSource(from);
1065
+ const doc = docs.find((candidate) => parser.detect(candidate)) ?? docs[0];
1066
+ if (doc === void 0) return importerErr("EMPTY_EXPORT", "The export was empty.");
1067
+ return ok({ parser, doc });
1068
+ }
1069
+ for (const parser of this.registry.autodetect) {
1070
+ const doc = docs.find((candidate) => parser.detect(candidate));
1071
+ if (doc !== void 0) return ok({ parser, doc });
1072
+ }
1073
+ return importerErr(
1074
+ "UNSUPPORTED_SOURCE",
1075
+ "Could not detect the export format. Pass --from chatgpt, claude, or generic."
1076
+ );
1077
+ }
1078
+ /** No selection flag (or an explicit --list) lists; otherwise --dry-run previews, else import. */
1079
+ resolveMode(input) {
1080
+ const hasSelection = input.all === true || input.pick !== void 0 || input.query !== void 0 || input.since !== void 0;
1081
+ if (input.list === true || !hasSelection) return "list";
1082
+ return input.dryRun === true ? "dry-run" : "import";
1083
+ }
1084
+ toListing(conversations, source) {
1085
+ return conversations.map((conversation, index) => ({
1086
+ index: index + 1,
1087
+ title: conversation.title,
1088
+ date: conversation.createdAt.toISOString().slice(0, 10),
1089
+ messageCount: conversation.messages.length,
1090
+ estimatedChunks: renderConversationChunks(conversation, source).length
1091
+ }));
1092
+ }
1093
+ buildItems(conversations, source) {
1094
+ const now = this.clock.now();
1095
+ const items = [];
1096
+ let skipped = 0;
1097
+ let contributed = 0;
1098
+ for (const conversation of conversations) {
1099
+ const chunks = renderConversationChunks(conversation, source);
1100
+ if (chunks.length === 0) {
1101
+ skipped += 1;
1102
+ continue;
1103
+ }
1104
+ contributed += 1;
1105
+ const createdAt = conversation.createdAt.getTime() === 0 ? now : conversation.createdAt;
1106
+ chunks.forEach((content, chunkIndex) => {
1107
+ items.push({
1108
+ source,
1109
+ conversationId: conversation.id,
1110
+ chunkIndex,
1111
+ content,
1112
+ createdAt,
1113
+ tags: ["imported", source]
1114
+ });
1115
+ });
1116
+ }
1117
+ return { items, skipped, contributed };
1118
+ }
1119
+ summary(mode, source, input, failures, counts) {
1120
+ return {
1121
+ mode,
1122
+ source,
1123
+ ...input.projectName === void 0 ? {} : { project: input.projectName },
1124
+ ...counts.listing === void 0 ? {} : { listing: counts.listing },
1125
+ imported: counts.imported ?? 0,
1126
+ conversations: counts.conversations ?? 0,
1127
+ skipped: counts.skipped ?? 0,
1128
+ failed: failures.length,
1129
+ failures
1130
+ };
1131
+ }
1132
+ };
1133
+
1134
+ // src/importers/infra/file-export-reader.ts
1135
+ import { readFileSync as readFileSync2 } from "fs";
1136
+ import { unzipSync } from "fflate";
1137
+ var MAX_ENTRY_BYTES = 128 * 1024 * 1024;
1138
+ var MAX_TOTAL_BYTES = 256 * 1024 * 1024;
1139
+ var decoder = new TextDecoder();
1140
+ function parseJsonBytes(bytes) {
1141
+ try {
1142
+ return ok(JSON.parse(decoder.decode(bytes)));
1143
+ } catch (error) {
1144
+ return importerErr("MALFORMED_EXPORT", `Invalid JSON: ${error.message}`);
1145
+ }
1146
+ }
1147
+ var FileExportReader = class {
1148
+ constructor(maxEntryBytes = MAX_ENTRY_BYTES, maxTotalBytes = MAX_TOTAL_BYTES) {
1149
+ this.maxEntryBytes = maxEntryBytes;
1150
+ this.maxTotalBytes = maxTotalBytes;
1151
+ }
1152
+ maxEntryBytes;
1153
+ maxTotalBytes;
1154
+ read(filePath) {
1155
+ let bytes;
1156
+ try {
1157
+ bytes = readFileSync2(filePath);
1158
+ } catch (error) {
1159
+ return importerErr(
1160
+ "UNREADABLE_FILE",
1161
+ `Could not read "${filePath}": ${error.message}`
1162
+ );
1163
+ }
1164
+ return filePath.toLowerCase().endsWith(".zip") ? this.readZip(bytes) : this.readJson(bytes);
1165
+ }
1166
+ readJson(bytes) {
1167
+ const parsed = parseJsonBytes(bytes);
1168
+ if (!parsed.ok) return parsed;
1169
+ return ok([parsed.value]);
1170
+ }
1171
+ readZip(bytes) {
1172
+ let entries;
1173
+ let total = 0;
1174
+ try {
1175
+ entries = unzipSync(bytes, {
1176
+ filter: (file) => {
1177
+ if (!file.name.toLowerCase().endsWith(".json")) return false;
1178
+ if (file.originalSize > this.maxEntryBytes) {
1179
+ throw new Error(`entry "${file.name}" exceeds the ${this.maxEntryBytes}-byte cap`);
1180
+ }
1181
+ total += file.originalSize;
1182
+ if (total > this.maxTotalBytes) {
1183
+ throw new Error(`archive exceeds the ${this.maxTotalBytes}-byte total cap`);
1184
+ }
1185
+ return true;
1186
+ }
1187
+ });
1188
+ } catch (error) {
1189
+ return importerErr(
1190
+ "CORRUPT_ARCHIVE",
1191
+ `Could not read the archive: ${error.message}`
1192
+ );
1193
+ }
1194
+ const docs = [];
1195
+ for (const entryBytes of Object.values(entries)) {
1196
+ const parsed = parseJsonBytes(entryBytes);
1197
+ if (!parsed.ok) return parsed;
1198
+ docs.push(parsed.value);
1199
+ }
1200
+ if (docs.length === 0) {
1201
+ return importerErr("EMPTY_EXPORT", "The archive contained no .json files.");
1202
+ }
1203
+ return ok(docs);
1204
+ }
1205
+ };
1206
+
1207
+ // src/importers/infra/parsers/chatgpt-parser.ts
1208
+ import { z as z2 } from "zod";
1209
+ var ChatgptMessage = z2.object({
1210
+ author: z2.object({ role: z2.string().optional() }).nullish(),
1211
+ content: z2.object({ parts: z2.array(z2.unknown()).optional() }).nullish(),
1212
+ create_time: z2.number().nullish()
1213
+ });
1214
+ var ChatgptConversation = z2.object({
1215
+ id: z2.string().optional(),
1216
+ conversation_id: z2.string().optional(),
1217
+ title: z2.string().optional(),
1218
+ create_time: z2.number().nullish(),
1219
+ mapping: z2.record(z2.string(), z2.object({ message: ChatgptMessage.nullish() }))
1220
+ });
1221
+ var HasMapping = z2.object({ mapping: z2.record(z2.string(), z2.unknown()) });
1222
+ function toArray(doc) {
1223
+ if (Array.isArray(doc)) return doc;
1224
+ if (doc !== null && typeof doc === "object" && "conversations" in doc) {
1225
+ const inner = doc.conversations;
1226
+ return Array.isArray(inner) ? inner : null;
1227
+ }
1228
+ return null;
1229
+ }
1230
+ function mapRole(role) {
1231
+ return role === "user" || role === "assistant" || role === "system" ? role : null;
1232
+ }
1233
+ function joinParts(parts) {
1234
+ if (!parts) return "";
1235
+ return parts.filter((part) => typeof part === "string").join("\n");
1236
+ }
1237
+ function linearize(data, index) {
1238
+ const timed = [];
1239
+ for (const node of Object.values(data.mapping)) {
1240
+ const raw = node.message;
1241
+ const role = mapRole(raw?.author?.role);
1242
+ if (raw === null || raw === void 0 || role === null) continue;
1243
+ const text = joinParts(raw.content?.parts);
1244
+ if (text.trim().length === 0) continue;
1245
+ const time = typeof raw.create_time === "number" ? raw.create_time : 0;
1246
+ const message = {
1247
+ role,
1248
+ content: text,
1249
+ ...typeof raw.create_time === "number" ? { createdAt: new Date(raw.create_time * 1e3) } : {}
1250
+ };
1251
+ timed.push({ message, time });
1252
+ }
1253
+ timed.sort((a, b) => a.time - b.time);
1254
+ const firstTime = timed[0]?.time;
1255
+ const convTime = data.create_time ?? firstTime;
1256
+ return {
1257
+ id: data.id ?? data.conversation_id ?? `chatgpt-${index}`,
1258
+ title: data.title ?? "Untitled",
1259
+ createdAt: typeof convTime === "number" ? new Date(convTime * 1e3) : /* @__PURE__ */ new Date(0),
1260
+ messages: timed.map((entry) => entry.message)
1261
+ };
1262
+ }
1263
+ var ChatgptParser = class {
1264
+ source = "chatgpt";
1265
+ detect(doc) {
1266
+ const arr = toArray(doc);
1267
+ const first = arr?.[0];
1268
+ return first !== void 0 && HasMapping.safeParse(first).success;
1269
+ }
1270
+ parse(doc) {
1271
+ const arr = toArray(doc);
1272
+ if (arr === null) {
1273
+ return importerErr("MALFORMED_EXPORT", "Expected an array of ChatGPT conversations.");
1274
+ }
1275
+ const conversations = [];
1276
+ const failures = [];
1277
+ for (const [index, raw] of arr.entries()) {
1278
+ const parsed = ChatgptConversation.safeParse(raw);
1279
+ if (!parsed.success) {
1280
+ failures.push({
1281
+ title: `Conversation ${index + 1}`,
1282
+ reason: parsed.error.issues[0]?.message ?? "invalid conversation shape"
1283
+ });
1284
+ continue;
1285
+ }
1286
+ conversations.push(linearize(parsed.data, index));
1287
+ }
1288
+ if (conversations.length === 0 && failures.length === 0) {
1289
+ return importerErr("EMPTY_EXPORT", "No conversations found in the ChatGPT export.");
1290
+ }
1291
+ return ok({ conversations, failures });
1292
+ }
1293
+ };
1294
+
1295
+ // src/importers/infra/parsers/claude-parser.ts
1296
+ import { z as z3 } from "zod";
1297
+ var ClaudeMessage = z3.object({
1298
+ sender: z3.string().optional(),
1299
+ text: z3.string().optional(),
1300
+ content: z3.array(z3.object({ type: z3.string().optional(), text: z3.string().optional() })).optional(),
1301
+ created_at: z3.string().optional()
1302
+ });
1303
+ var ClaudeConversation = z3.object({
1304
+ uuid: z3.string().optional(),
1305
+ name: z3.string().optional(),
1306
+ created_at: z3.string().optional(),
1307
+ chat_messages: z3.array(ClaudeMessage)
1308
+ });
1309
+ var HasChatMessages = z3.object({ chat_messages: z3.array(z3.unknown()) });
1310
+ function parseDate(iso) {
1311
+ if (iso === void 0) return null;
1312
+ const date = new Date(iso);
1313
+ return Number.isNaN(date.getTime()) ? null : date;
1314
+ }
1315
+ function messageText(message) {
1316
+ if (message.text !== void 0 && message.text.trim().length > 0) return message.text;
1317
+ if (message.content === void 0) return "";
1318
+ return message.content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
1319
+ }
1320
+ var ClaudeParser = class {
1321
+ source = "claude";
1322
+ detect(doc) {
1323
+ const first = Array.isArray(doc) ? doc[0] : void 0;
1324
+ return first !== void 0 && HasChatMessages.safeParse(first).success;
1325
+ }
1326
+ parse(doc) {
1327
+ if (!Array.isArray(doc)) {
1328
+ return importerErr("MALFORMED_EXPORT", "Expected an array of Claude conversations.");
1329
+ }
1330
+ const conversations = [];
1331
+ const failures = [];
1332
+ for (const [index, raw] of doc.entries()) {
1333
+ const parsed = ClaudeConversation.safeParse(raw);
1334
+ if (!parsed.success) {
1335
+ failures.push({
1336
+ title: `Conversation ${index + 1}`,
1337
+ reason: parsed.error.issues[0]?.message ?? "invalid conversation shape"
1338
+ });
1339
+ continue;
1340
+ }
1341
+ const messages = [];
1342
+ for (const rawMessage of parsed.data.chat_messages) {
1343
+ const role = rawMessage.sender === "human" ? "user" : "assistant";
1344
+ const text = messageText(rawMessage);
1345
+ if (text.trim().length === 0) continue;
1346
+ const at = parseDate(rawMessage.created_at);
1347
+ messages.push({ role, content: text, ...at === null ? {} : { createdAt: at } });
1348
+ }
1349
+ const firstAt = messages[0]?.createdAt;
1350
+ const convAt = parseDate(parsed.data.created_at) ?? firstAt ?? /* @__PURE__ */ new Date(0);
1351
+ conversations.push({
1352
+ id: parsed.data.uuid ?? `claude-${index}`,
1353
+ title: parsed.data.name ?? "Untitled",
1354
+ createdAt: convAt,
1355
+ messages
1356
+ });
1357
+ }
1358
+ if (conversations.length === 0 && failures.length === 0) {
1359
+ return importerErr("EMPTY_EXPORT", "No conversations found in the Claude export.");
1360
+ }
1361
+ return ok({ conversations, failures });
1362
+ }
1363
+ };
1364
+
1365
+ // src/importers/infra/parsers/generic-parser.ts
1366
+ import { z as z4 } from "zod";
1367
+ var GENERIC_IMPORT_VERSION = 1;
1368
+ var GenericMessage = z4.object({
1369
+ role: z4.enum(["user", "assistant", "system"]),
1370
+ content: z4.string(),
1371
+ createdAt: z4.string().optional()
1372
+ });
1373
+ var GenericConversation = z4.object({
1374
+ id: z4.string(),
1375
+ title: z4.string().optional(),
1376
+ createdAt: z4.string(),
1377
+ messages: z4.array(GenericMessage)
1378
+ });
1379
+ var GenericEnvelope = z4.object({
1380
+ valija_import_version: z4.number(),
1381
+ conversations: z4.array(z4.unknown())
1382
+ });
1383
+ function parseDate2(iso) {
1384
+ if (iso === void 0) return null;
1385
+ const date = new Date(iso);
1386
+ return Number.isNaN(date.getTime()) ? null : date;
1387
+ }
1388
+ var GenericParser = class {
1389
+ source = "generic";
1390
+ detect(doc) {
1391
+ return doc !== null && typeof doc === "object" && !Array.isArray(doc) && "valija_import_version" in doc;
1392
+ }
1393
+ parse(doc) {
1394
+ const envelope = GenericEnvelope.safeParse(doc);
1395
+ if (!envelope.success) {
1396
+ return importerErr("MALFORMED_EXPORT", "Not a valija generic import envelope.");
1397
+ }
1398
+ if (envelope.data.valija_import_version !== GENERIC_IMPORT_VERSION) {
1399
+ return importerErr(
1400
+ "UNSUPPORTED_GENERIC_VERSION",
1401
+ `Unsupported generic import version ${envelope.data.valija_import_version}; this valija supports version ${GENERIC_IMPORT_VERSION}.`
1402
+ );
1403
+ }
1404
+ const conversations = [];
1405
+ const failures = [];
1406
+ for (const [index, raw] of envelope.data.conversations.entries()) {
1407
+ const parsed = GenericConversation.safeParse(raw);
1408
+ if (!parsed.success) {
1409
+ failures.push({
1410
+ title: `Conversation ${index + 1}`,
1411
+ reason: parsed.error.issues[0]?.message ?? "invalid conversation shape"
1412
+ });
1413
+ continue;
1414
+ }
1415
+ const createdAt = parseDate2(parsed.data.createdAt);
1416
+ if (createdAt === null) {
1417
+ failures.push({
1418
+ title: parsed.data.title ?? parsed.data.id,
1419
+ reason: `Invalid createdAt: "${parsed.data.createdAt}"`
1420
+ });
1421
+ continue;
1422
+ }
1423
+ const messages = parsed.data.messages.map((message) => {
1424
+ const at = parseDate2(message.createdAt);
1425
+ return {
1426
+ role: message.role,
1427
+ content: message.content,
1428
+ ...at === null ? {} : { createdAt: at }
1429
+ };
1430
+ });
1431
+ conversations.push({
1432
+ id: parsed.data.id,
1433
+ title: parsed.data.title ?? "Untitled",
1434
+ createdAt,
1435
+ messages
1436
+ });
1437
+ }
1438
+ if (conversations.length === 0 && failures.length === 0) {
1439
+ return importerErr("EMPTY_EXPORT", "No conversations found in the generic export.");
1440
+ }
1441
+ return ok({ conversations, failures });
1442
+ }
1443
+ };
1444
+
1445
+ // src/importers/infra/parser-registry.ts
1446
+ var chatgpt = new ChatgptParser();
1447
+ var claude = new ClaudeParser();
1448
+ var generic = new GenericParser();
1449
+ var BY_SOURCE = { chatgpt, claude, generic };
1450
+ var parserRegistry = {
1451
+ autodetect: [chatgpt, claude],
1452
+ forSource: (source) => BY_SOURCE[source]
1453
+ };
1454
+
707
1455
  // src/shared/infra/vault-paths.ts
708
1456
  import { homedir } from "os";
709
1457
  import { join } from "path";
@@ -716,10 +1464,22 @@ function resolveVaultPaths(rootOverride) {
716
1464
  };
717
1465
  }
718
1466
 
719
- // src/vault/application/hex.ts
1467
+ // src/vault/domain/values/key-hex.ts
720
1468
  var bytesToHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
721
1469
  var isKeyHex = (value) => /^[0-9a-f]{64}$/i.test(value);
722
1470
 
1471
+ // src/vault/domain/values/passphrase.ts
1472
+ var MIN_PASSPHRASE_LENGTH = 8;
1473
+ function parsePassphrase(raw) {
1474
+ if (raw.length < MIN_PASSPHRASE_LENGTH) {
1475
+ return vaultErr(
1476
+ "WEAK_PASSPHRASE",
1477
+ `Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters.`
1478
+ );
1479
+ }
1480
+ return ok(raw);
1481
+ }
1482
+
723
1483
  // src/vault/application/ports/crypto.ts
724
1484
  var DEFAULT_KDF_PARAMS = {
725
1485
  algorithm: "argon2id",
@@ -728,8 +1488,7 @@ var DEFAULT_KDF_PARAMS = {
728
1488
  parallelism: 1
729
1489
  };
730
1490
 
731
- // src/vault/application/create-vault.ts
732
- var MIN_PASSPHRASE_LENGTH = 8;
1491
+ // src/vault/application/use-cases/create-vault.use-case.ts
733
1492
  var CreateVault = class {
734
1493
  constructor(store, crypto, keychain, clock, idGen) {
735
1494
  this.store = store;
@@ -747,26 +1506,38 @@ var CreateVault = class {
747
1506
  if (this.store.headerExists()) {
748
1507
  return vaultErr("VAULT_ALREADY_EXISTS", "A vault already exists on this machine.");
749
1508
  }
750
- if (passphrase.length < MIN_PASSPHRASE_LENGTH) {
751
- return vaultErr(
752
- "WEAK_PASSPHRASE",
753
- `Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters.`
754
- );
755
- }
1509
+ const parsed = parsePassphrase(passphrase);
1510
+ if (!parsed.ok) return parsed;
1511
+ const vault = await this.forgeVault(parsed.value);
1512
+ this.store.writeHeader(vault.header);
1513
+ const initialized = this.store.initializeDb(vault.keyHex);
1514
+ if (!initialized.ok) return initialized;
1515
+ this.keychain.setKey(vault.header.vaultId, vault.keyHex);
1516
+ return ok({
1517
+ vaultId: vault.header.vaultId,
1518
+ keyHex: vault.keyHex,
1519
+ createdAt: vault.header.createdAt
1520
+ });
1521
+ }
1522
+ /** Mint the vault identity: id + salt + key derived from the passphrase. */
1523
+ async forgeVault(passphrase) {
756
1524
  const vaultId = this.idGen.next();
757
1525
  const salt = this.crypto.generateSalt();
758
1526
  const key = await this.crypto.deriveKey(passphrase, salt, DEFAULT_KDF_PARAMS);
759
- const keyHex = bytesToHex(key);
760
- const createdAt = this.clock.now().toISOString();
761
- this.store.writeHeader({ vaultId, schemaVersion: 1, kdf: DEFAULT_KDF_PARAMS, salt, createdAt });
762
- const init = this.store.initializeDb(keyHex);
763
- if (!init.ok) return init;
764
- this.keychain.setKey(vaultId, keyHex);
765
- return ok({ vaultId, keyHex, createdAt });
1527
+ return {
1528
+ header: {
1529
+ vaultId,
1530
+ schemaVersion: 1,
1531
+ kdf: DEFAULT_KDF_PARAMS,
1532
+ salt,
1533
+ createdAt: this.clock.now().toISOString()
1534
+ },
1535
+ keyHex: bytesToHex(key)
1536
+ };
766
1537
  }
767
1538
  };
768
1539
 
769
- // src/vault/application/lock-vault.ts
1540
+ // src/vault/application/use-cases/lock-vault.use-case.ts
770
1541
  var LockVault = class {
771
1542
  constructor(store, keychain) {
772
1543
  this.store = store;
@@ -782,7 +1553,7 @@ var LockVault = class {
782
1553
  }
783
1554
  };
784
1555
 
785
- // src/vault/application/unlock-vault.ts
1556
+ // src/vault/application/use-cases/unlock-vault.use-case.ts
786
1557
  var UnlockVault = class {
787
1558
  constructor(store, crypto, keychain) {
788
1559
  this.store = store;
@@ -795,30 +1566,30 @@ var UnlockVault = class {
795
1566
  async execute(input) {
796
1567
  const header = this.store.readHeader();
797
1568
  if (!header.ok) return header;
798
- let keyHex;
1569
+ const keyHex = await this.resolveKey(input, header.value);
1570
+ if (!keyHex.ok) return keyHex;
1571
+ const verified = this.store.verifyKey(keyHex.value);
1572
+ if (!verified.ok) return verified;
1573
+ this.keychain.setKey(header.value.vaultId, keyHex.value);
1574
+ return ok({ vaultId: header.value.vaultId });
1575
+ }
1576
+ /** A recovery key is used as-is; a passphrase is derived with the header's salt + KDF params. */
1577
+ async resolveKey(input, header) {
799
1578
  if (input.recoveryKeyHex !== void 0) {
800
1579
  if (!isKeyHex(input.recoveryKeyHex)) {
801
1580
  return vaultErr("WRONG_PASSPHRASE", "Recovery key must be 64 hex characters.");
802
1581
  }
803
- keyHex = input.recoveryKeyHex.toLowerCase();
804
- } else if (input.passphrase !== void 0) {
805
- const key = await this.crypto.deriveKey(
806
- input.passphrase,
807
- header.value.salt,
808
- header.value.kdf
809
- );
810
- keyHex = bytesToHex(key);
811
- } else {
812
- return vaultErr("WRONG_PASSPHRASE", "Provide a passphrase or a recovery key.");
1582
+ return ok(input.recoveryKeyHex.toLowerCase());
813
1583
  }
814
- const verified = this.store.verifyKey(keyHex);
815
- if (!verified.ok) return verified;
816
- this.keychain.setKey(header.value.vaultId, keyHex);
817
- return ok({ vaultId: header.value.vaultId });
1584
+ if (input.passphrase !== void 0) {
1585
+ const key = await this.crypto.deriveKey(input.passphrase, header.salt, header.kdf);
1586
+ return ok(bytesToHex(key));
1587
+ }
1588
+ return vaultErr("WRONG_PASSPHRASE", "Provide a passphrase or a recovery key.");
818
1589
  }
819
1590
  };
820
1591
 
821
- // src/vault/application/vault-status.ts
1592
+ // src/vault/application/use-cases/vault-status.use-case.ts
822
1593
  var VaultStatus = class {
823
1594
  constructor(store, keychain) {
824
1595
  this.store = store;
@@ -882,7 +1653,7 @@ var FileVaultStore = class {
882
1653
  initializeDb(keyHex) {
883
1654
  try {
884
1655
  const db = openVaultDb(this.paths.db, keyHex);
885
- migrate(db);
1656
+ migrate(db, this.paths.db);
886
1657
  db.close();
887
1658
  return ok(void 0);
888
1659
  } catch (error) {
@@ -937,8 +1708,8 @@ function buildContainer() {
937
1708
  const store = new FileVaultStore(paths);
938
1709
  const crypto = new Argon2VaultCrypto();
939
1710
  const keychain = new OsKeychain();
940
- const sessions = new SqliteVaultSessionFactory(paths, keychain);
941
- const getContextPack = new GetContextPack(sessions, systemClock);
1711
+ const sessions = new SqliteVaultSessions(paths, keychain);
1712
+ const importItems = new ImportItems(sessions, systemClock, ulidIds);
942
1713
  return {
943
1714
  paths,
944
1715
  createVault: new CreateVault(store, crypto, keychain, systemClock, ulidIds),
@@ -948,19 +1719,64 @@ function buildContainer() {
948
1719
  saveContext: new SaveContext(sessions, systemClock, ulidIds),
949
1720
  listProjects: new ListProjects(sessions),
950
1721
  searchContext: new SearchContext(sessions),
951
- getContextPack,
952
- exportPack: new ExportPack(sessions, getContextPack),
953
- showProject: new ShowProject(sessions)
1722
+ getContextPack: new GetContextPack(sessions, systemClock),
1723
+ showProject: new ShowProject(sessions),
1724
+ importConversations: new ImportConversations(
1725
+ new FileExportReader(),
1726
+ parserRegistry,
1727
+ importItems,
1728
+ systemClock
1729
+ )
954
1730
  };
955
1731
  }
956
1732
 
957
1733
  // src/delivery/mcp/server.ts
958
1734
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
959
1735
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
960
- import { z as z2 } from "zod";
1736
+ import { z as z5 } from "zod";
1737
+
1738
+ // src/delivery/context-pack-markdown.ts
1739
+ var TYPE_LABELS = {
1740
+ decision: "Decisions",
1741
+ preference: "Preferences",
1742
+ progress: "Progress",
1743
+ fact: "Facts",
1744
+ handoff: "Handoffs"
1745
+ };
1746
+ var sectionTitle = (section) => {
1747
+ switch (section.kind) {
1748
+ case "pinned":
1749
+ return "Pinned";
1750
+ case "latest-handoff":
1751
+ return "Latest handoff";
1752
+ case "by-type":
1753
+ return TYPE_LABELS[section.type];
1754
+ }
1755
+ };
1756
+ var renderItem = (item) => {
1757
+ const date = item.createdAt.toISOString().slice(0, 10);
1758
+ const tags = item.tags.length > 0 ? ` \xB7 ${item.tags.map((t) => `#${t}`).join(" ")}` : "";
1759
+ return `### ${item.type} \xB7 ${date}${tags}
1760
+
1761
+ ${item.content}
1762
+ `;
1763
+ };
1764
+ function renderContextPackMarkdown(pack) {
1765
+ const header = `# Context pack: ${pack.projectName}
1766
+
1767
+ > ${pack.totalCount} items in vault \xB7 generated ${pack.generatedAt.toISOString()}
1768
+ `;
1769
+ const parts = pack.sections.flatMap((section) => [
1770
+ `
1771
+ ## ${sectionTitle(section)}
1772
+ `,
1773
+ ...section.items.map(renderItem)
1774
+ ]);
1775
+ return header + parts.join("\n");
1776
+ }
961
1777
 
962
1778
  // src/delivery/version.ts
963
- var VERSION = "0.1.0";
1779
+ var VERSION = "0.2.0";
964
1780
 
965
1781
  // src/delivery/mcp/server.ts
966
1782
  var ok2 = (text) => ({ content: [{ type: "text", text }] });
@@ -984,10 +1800,10 @@ function buildMcpServer(c) {
984
1800
  title: "Save context",
985
1801
  description: DESCRIPTIONS.save_context,
986
1802
  inputSchema: {
987
- project: z2.string().describe("Project slug, e.g. 'vault-app'. Created if it does not exist."),
988
- content: z2.string().describe("Self-contained markdown. Max 32 KB."),
989
- type: z2.enum(ITEM_TYPES).optional().describe("Kind of context. Defaults to 'fact'."),
990
- tags: z2.array(z2.string()).max(10).optional().describe("Lowercase tags for recall.")
1803
+ project: z5.string().describe("Project slug, e.g. 'vault-app'. Created if it does not exist."),
1804
+ content: z5.string().describe("Self-contained markdown. Max 32 KB."),
1805
+ type: z5.enum(ITEM_TYPES).optional().describe("Kind of context. Defaults to 'fact'."),
1806
+ tags: z5.array(z5.string()).max(10).optional().describe("Lowercase tags for recall.")
991
1807
  }
992
1808
  },
993
1809
  async ({ project, content, type, tags }) => {
@@ -1011,8 +1827,8 @@ function buildMcpServer(c) {
1011
1827
  title: "Save handoff",
1012
1828
  description: DESCRIPTIONS.save_handoff,
1013
1829
  inputSchema: {
1014
- project: z2.string().describe("Project slug. Created if it does not exist."),
1015
- content: z2.string().describe("Goal, what was done, blockers, exact next step, relevant files or links.")
1830
+ project: z5.string().describe("Project slug. Created if it does not exist."),
1831
+ content: z5.string().describe("Goal, what was done, blockers, exact next step, relevant files or links.")
1016
1832
  }
1017
1833
  },
1018
1834
  async ({ project, content }) => {
@@ -1033,8 +1849,8 @@ function buildMcpServer(c) {
1033
1849
  title: "Get context pack",
1034
1850
  description: DESCRIPTIONS.get_context,
1035
1851
  inputSchema: {
1036
- project: z2.string().describe("Project slug to load."),
1037
- budget: z2.number().int().positive().optional().describe("Token budget. Default 4000.")
1852
+ project: z5.string().describe("Project slug to load."),
1853
+ budget: z5.number().int().positive().optional().describe("Token budget. Default 4000.")
1038
1854
  }
1039
1855
  },
1040
1856
  async ({ project, budget }) => {
@@ -1043,7 +1859,7 @@ function buildMcpServer(c) {
1043
1859
  ...budget === void 0 ? {} : { budgetTokens: budget }
1044
1860
  });
1045
1861
  if (!r.ok) return err2(r.error);
1046
- return ok2(r.value.markdown);
1862
+ return ok2(renderContextPackMarkdown(r.value));
1047
1863
  }
1048
1864
  );
1049
1865
  server.registerTool(
@@ -1052,9 +1868,9 @@ function buildMcpServer(c) {
1052
1868
  title: "Search context",
1053
1869
  description: DESCRIPTIONS.search_context,
1054
1870
  inputSchema: {
1055
- query: z2.string().describe("Full-text query, e.g. 'auth decision'."),
1056
- project: z2.string().optional().describe("Limit to one project."),
1057
- limit: z2.number().int().positive().max(100).optional().describe("Max results. Default 20.")
1871
+ query: z5.string().describe("Full-text query, e.g. 'auth decision'."),
1872
+ project: z5.string().optional().describe("Limit to one project."),
1873
+ limit: z5.number().int().positive().max(100).optional().describe("Max results. Default 20.")
1058
1874
  }
1059
1875
  },
1060
1876
  async ({ query, project, limit }) => {
@@ -1095,7 +1911,7 @@ ${lines.join("\n")}`);
1095
1911
  {
1096
1912
  title: "Save session context to valija",
1097
1913
  description: "Distill this session into a self-contained context save.",
1098
- argsSchema: { project: z2.string().optional() }
1914
+ argsSchema: { project: z5.string().optional() }
1099
1915
  },
1100
1916
  ({ project }) => ({
1101
1917
  messages: [
@@ -1119,7 +1935,7 @@ ${lines.join("\n")}`);
1119
1935
  {
1120
1936
  title: "Load project context from valija",
1121
1937
  description: "Load a project's context pack and resume work.",
1122
- argsSchema: { project: z2.string() }
1938
+ argsSchema: { project: z5.string() }
1123
1939
  },
1124
1940
  ({ project }) => ({
1125
1941
  messages: [
@@ -1177,7 +1993,10 @@ function projectsCommand(c) {
1177
1993
  }
1178
1994
  }
1179
1995
  function showCommand(c, project, options) {
1180
- const result = c.showProject.execute(project, options.type);
1996
+ const result = c.showProject.execute({
1997
+ project,
1998
+ ...options.type === void 0 ? {} : { type: options.type }
1999
+ });
1181
2000
  if (!result.ok) fail(result.error);
1182
2001
  if (result.value.length === 0) {
1183
2002
  console.log("No items.");
@@ -1207,9 +2026,17 @@ function searchCommand(c, query, options) {
1207
2026
  );
1208
2027
  }
1209
2028
  }
2029
+ var exportMarkdown = (c, project) => {
2030
+ const pack = c.getContextPack.execute({ project, budgetTokens: Number.POSITIVE_INFINITY });
2031
+ return pack.ok ? ok(renderContextPackMarkdown(pack.value)) : pack;
2032
+ };
2033
+ var exportJson = (c, project) => {
2034
+ const items = c.showProject.execute({ project });
2035
+ return items.ok ? ok(JSON.stringify({ project, items: items.value }, null, 2)) : items;
2036
+ };
1210
2037
  function exportCommand(c, project, options) {
1211
2038
  const format = options.json ? "json" : "md";
1212
- const result = c.exportPack.execute(project, format);
2039
+ const result = options.json ? exportJson(c, project) : exportMarkdown(c, project);
1213
2040
  if (!result.ok) fail(result.error);
1214
2041
  if (options.output !== void 0) {
1215
2042
  writeFileSync2(options.output, result.value, "utf8");
@@ -1220,10 +2047,10 @@ function exportCommand(c, project, options) {
1220
2047
  }
1221
2048
 
1222
2049
  // src/delivery/cli/doctor.ts
1223
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
2050
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
1224
2051
 
1225
2052
  // src/delivery/cli/installer.ts
1226
- import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
2053
+ import { copyFileSync as copyFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1227
2054
  import { homedir as homedir2 } from "os";
1228
2055
  import { dirname as dirname2, join as join2 } from "path";
1229
2056
  var CLIENTS = ["claude-code", "claude-desktop", "cursor"];
@@ -1248,24 +2075,32 @@ function clientConfigPath(client, platform = process.platform) {
1248
2075
  }
1249
2076
  }
1250
2077
  }
1251
- function installIntoClient(client) {
1252
- const configPath = clientConfigPath(client);
1253
- let existing = {};
1254
- let backupPath = null;
1255
- if (existsSync3(configPath)) {
1256
- const raw = readFileSync2(configPath, "utf8");
1257
- const parsed = JSON.parse(raw);
1258
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1259
- throw new Error(`${configPath} does not contain a JSON object; not touching it.`);
1260
- }
1261
- existing = parsed;
1262
- backupPath = `${configPath}.backup-${Date.now()}`;
1263
- copyFileSync(configPath, backupPath);
1264
- } else {
2078
+ function readExistingConfig(configPath) {
2079
+ if (!existsSync3(configPath)) return {};
2080
+ const parsed = JSON.parse(readFileSync3(configPath, "utf8"));
2081
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2082
+ throw new Error(`${configPath} does not contain a JSON object; not touching it.`);
2083
+ }
2084
+ return parsed;
2085
+ }
2086
+ function backupExisting(configPath) {
2087
+ if (!existsSync3(configPath)) {
1265
2088
  mkdirSync3(dirname2(configPath), { recursive: true });
2089
+ return null;
1266
2090
  }
2091
+ const backupPath = `${configPath}.backup-${Date.now()}`;
2092
+ copyFileSync2(configPath, backupPath);
2093
+ return backupPath;
2094
+ }
2095
+ function mergeValijaEntry(existing) {
1267
2096
  const servers = typeof existing.mcpServers === "object" && existing.mcpServers !== null ? existing.mcpServers : {};
1268
- const merged = { ...existing, mcpServers: { ...servers, valija: MCP_ENTRY } };
2097
+ return { ...existing, mcpServers: { ...servers, valija: MCP_ENTRY } };
2098
+ }
2099
+ function installIntoClient(client) {
2100
+ const configPath = clientConfigPath(client);
2101
+ const existing = readExistingConfig(configPath);
2102
+ const backupPath = backupExisting(configPath);
2103
+ const merged = mergeValijaEntry(existing);
1269
2104
  writeFileSync3(configPath, `${JSON.stringify(merged, null, 2)}
1270
2105
  `, "utf8");
1271
2106
  return { configPath, backupPath };
@@ -1278,59 +2113,69 @@ function manualInstructions(client) {
1278
2113
  }
1279
2114
 
1280
2115
  // src/delivery/cli/doctor.ts
1281
- async function doctorCommand(c) {
1282
- const checks = [];
2116
+ function checkNode() {
1283
2117
  const [major] = process.versions.node.split(".").map(Number);
1284
- checks.push({
2118
+ return {
1285
2119
  name: "node",
1286
2120
  ok: (major ?? 0) >= 22,
1287
2121
  detail: `v${process.versions.node} (need >=22)`,
1288
2122
  fatal: true
1289
- });
2123
+ };
2124
+ }
2125
+ async function checkSqlcipher() {
1290
2126
  try {
1291
2127
  const { default: Db } = await import("better-sqlite3-multiple-ciphers");
1292
2128
  const db = new Db(":memory:");
1293
2129
  db.pragma("cipher='sqlcipher'");
1294
2130
  db.close();
1295
- checks.push({ name: "sqlcipher", ok: true, detail: "native module loads" });
2131
+ return { name: "sqlcipher", ok: true, detail: "native module loads" };
1296
2132
  } catch (e) {
1297
- checks.push({ name: "sqlcipher", ok: false, detail: e.message, fatal: true });
2133
+ return { name: "sqlcipher", ok: false, detail: e.message, fatal: true };
1298
2134
  }
2135
+ }
2136
+ function checkKeychain() {
1299
2137
  try {
1300
2138
  const keychain = new OsKeychain();
1301
2139
  keychain.setKey("doctor-probe", "test");
1302
2140
  const roundtrip = keychain.getKey("doctor-probe") === "test";
1303
2141
  keychain.deleteKey("doctor-probe");
1304
- checks.push({ name: "keychain", ok: roundtrip, detail: "OS keychain read/write" });
2142
+ return { name: "keychain", ok: roundtrip, detail: "OS keychain read/write" };
1305
2143
  } catch (e) {
1306
- checks.push({ name: "keychain", ok: false, detail: e.message });
2144
+ return { name: "keychain", ok: false, detail: e.message };
1307
2145
  }
2146
+ }
2147
+ function checkVault(c) {
1308
2148
  const status = c.vaultStatus.execute();
1309
- if (status.ok) {
1310
- checks.push({
1311
- name: "vault",
1312
- ok: status.value.initialized,
1313
- detail: status.value.initialized ? `${status.value.unlocked ? "unlocked" : "locked"} at ${status.value.dbPath}` : 'not initialized \u2014 run "valija init"'
1314
- });
1315
- } else {
1316
- checks.push({ name: "vault", ok: false, detail: status.error.message });
1317
- }
1318
- for (const client of CLIENTS) {
1319
- const path = clientConfigPath(client);
1320
- let detail = "config not found";
1321
- let installed = false;
1322
- if (existsSync4(path)) {
1323
- detail = "config found, valija not installed";
1324
- try {
1325
- const parsed = JSON.parse(readFileSync3(path, "utf8"));
1326
- installed = parsed.mcpServers?.valija !== void 0;
1327
- if (installed) detail = "valija installed";
1328
- } catch {
1329
- detail = "config exists but is not valid JSON";
1330
- }
1331
- }
1332
- checks.push({ name: client, ok: installed, detail });
2149
+ if (!status.ok) return { name: "vault", ok: false, detail: status.error.message };
2150
+ return {
2151
+ name: "vault",
2152
+ ok: status.value.initialized,
2153
+ detail: status.value.initialized ? `${status.value.unlocked ? "unlocked" : "locked"} at ${status.value.dbPath}` : 'not initialized \u2014 run "valija init"'
2154
+ };
2155
+ }
2156
+ function checkClient(client) {
2157
+ const path = clientConfigPath(client);
2158
+ if (!existsSync4(path)) return { name: client, ok: false, detail: "config not found" };
2159
+ try {
2160
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
2161
+ const installed = parsed.mcpServers?.valija !== void 0;
2162
+ return {
2163
+ name: client,
2164
+ ok: installed,
2165
+ detail: installed ? "valija installed" : "config found, valija not installed"
2166
+ };
2167
+ } catch {
2168
+ return { name: client, ok: false, detail: "config exists but is not valid JSON" };
1333
2169
  }
2170
+ }
2171
+ async function doctorCommand(c) {
2172
+ const checks = [
2173
+ checkNode(),
2174
+ await checkSqlcipher(),
2175
+ checkKeychain(),
2176
+ checkVault(c),
2177
+ ...CLIENTS.map(checkClient)
2178
+ ];
1334
2179
  let fatal = false;
1335
2180
  for (const check of checks) {
1336
2181
  console.log(`${check.ok ? "\u2713" : "\u2717"} ${check.name.padEnd(16)} ${check.detail}`);
@@ -1339,6 +2184,75 @@ async function doctorCommand(c) {
1339
2184
  if (fatal) process.exit(1);
1340
2185
  }
1341
2186
 
2187
+ // src/importers/domain/values/import-source.ts
2188
+ var IMPORT_SOURCES = ["chatgpt", "claude", "generic"];
2189
+ function parseImportSource(raw) {
2190
+ if (IMPORT_SOURCES.includes(raw)) {
2191
+ return ok(raw);
2192
+ }
2193
+ return importerErr(
2194
+ "UNSUPPORTED_SOURCE",
2195
+ `Source must be one of ${IMPORT_SOURCES.join(", ")}. Got: "${raw}"`
2196
+ );
2197
+ }
2198
+
2199
+ // src/delivery/cli/import-command.ts
2200
+ function importCommand(c, file, options) {
2201
+ const hasSelection = options.all === true || options.pick !== void 0 || options.query !== void 0 || options.since !== void 0;
2202
+ if (hasSelection && options.project === void 0) {
2203
+ console.error("error: -p <project> is required to import. Run with --list first to preview.");
2204
+ process.exit(1);
2205
+ }
2206
+ const input = {
2207
+ filePath: file,
2208
+ ...options.project === void 0 ? {} : { projectName: options.project },
2209
+ ...options.from === void 0 ? {} : { from: resolveSource(options.from) },
2210
+ ...options.list === void 0 ? {} : { list: options.list },
2211
+ ...options.pick === void 0 ? {} : { pick: options.pick },
2212
+ ...options.query === void 0 ? {} : { query: options.query },
2213
+ ...options.since === void 0 ? {} : { since: options.since },
2214
+ ...options.all === void 0 ? {} : { all: options.all },
2215
+ ...options.dryRun === void 0 ? {} : { dryRun: options.dryRun }
2216
+ };
2217
+ const result = c.importConversations.execute(input);
2218
+ if (!result.ok) fail(result.error);
2219
+ if (result.value.mode === "list") renderListing(result.value);
2220
+ else renderSummary(result.value);
2221
+ }
2222
+ function resolveSource(raw) {
2223
+ const parsed = parseImportSource(raw);
2224
+ if (!parsed.ok) fail(parsed.error);
2225
+ return parsed.value;
2226
+ }
2227
+ function renderListing(summary) {
2228
+ const rows = summary.listing ?? [];
2229
+ if (rows.length === 0) {
2230
+ console.log("No conversations found in this export.");
2231
+ return;
2232
+ }
2233
+ console.log(`${rows.length} conversation(s) in this ${summary.source} export:
2234
+ `);
2235
+ console.log(" # DATE CHUNKS TITLE");
2236
+ for (const row of rows) {
2237
+ console.log(
2238
+ `${String(row.index).padStart(3)} ${row.date} ${String(row.estimatedChunks).padStart(6)} ${truncate(row.title, 48)}`
2239
+ );
2240
+ }
2241
+ console.log(
2242
+ "\nSelect with --pick 1,3-5, --query <text>, --since <YYYY-MM-DD>, or --all. Add --dry-run to preview."
2243
+ );
2244
+ }
2245
+ function renderSummary(summary) {
2246
+ const target = summary.project === void 0 ? "" : ` into "${summary.project}"`;
2247
+ const verb = summary.mode === "dry-run" ? "Would import" : "Imported";
2248
+ console.log(
2249
+ `${verb} ${summary.imported} item(s) from ${summary.conversations} conversation(s)${target} (skipped ${summary.skipped}, failed ${summary.failed}).`
2250
+ );
2251
+ for (const failure of summary.failures) {
2252
+ console.error(` failed: ${failure.conversation} \u2014 ${failure.reason}`);
2253
+ }
2254
+ }
2255
+
1342
2256
  // src/vault/infra/recovery-kit.ts
1343
2257
  function renderRecoveryKit(vaultId, keyHex, createdAt) {
1344
2258
  return `================================================================
@@ -1490,7 +2404,7 @@ program.command("unlock").description("Unlock the vault for this session (key go
1490
2404
  program.command("lock").description("Lock the vault (remove the key from the OS keychain).").action(() => lockCommand(container));
1491
2405
  program.command("status").description("Show vault location and lock state.").action(() => statusCommand(container));
1492
2406
  program.command("projects").description("List projects with item counts and last activity.").action(() => projectsCommand(container));
1493
- program.command("show").description("Print the items of a project.").argument("<project>", "project slug").option("--type <type>", "filter by type (decision|progress|preference|fact|handoff)").action(
2407
+ program.command("show").description("Print the items of a project.").argument("<project>", "project slug").option("--type <type>", "filter by type (decision|progress|preference|fact|handoff|imported)").action(
1494
2408
  (project, options) => showCommand(container, project, options)
1495
2409
  );
1496
2410
  program.command("search").description("Full-text search across saved context.").argument("<query>", "search terms").option("-p, --project <project>", "limit to one project").action(
@@ -1499,6 +2413,9 @@ program.command("search").description("Full-text search across saved context.").
1499
2413
  program.command("export").description("Export a project's context pack (markdown by default).").argument("<project>", "project slug").option("--json", "export as JSON instead of markdown").option("-o, --output <file>", "write to a file instead of stdout").action(
1500
2414
  (project, options) => exportCommand(container, project, options)
1501
2415
  );
2416
+ program.command("import").description("Import chatbot history (ChatGPT/Claude/generic export) into the vault.").argument("<file>", "path to an export file (.json or .zip)").option("-p, --project <project>", "target project (required to import; created if new)").option("--from <source>", "chatgpt | claude | generic (auto-detected if omitted)").option("--list", "list conversations and exit (the default when no selection is given)").option("--pick <spec>", "1-based indices/ranges into the list, e.g. 1,3-5").option("--query <text>", "select conversations whose title contains this text").option("--since <date>", "select conversations on or after YYYY-MM-DD").option("--all", "import every conversation").option("--dry-run", "report what would be imported; write nothing").action(
2417
+ (file, options) => importCommand(container, file, options)
2418
+ );
1502
2419
  program.command("install").description("Wire the valija MCP server into an AI tool's config.").argument("<client>", `one of: ${CLIENTS.join(", ")}`).action((client) => {
1503
2420
  if (!CLIENTS.includes(client)) {
1504
2421
  console.error(`error: unknown client "${client}". Use one of: ${CLIENTS.join(", ")}`);