@ectplsm/relic 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +5 -3
  2. package/dist/adapters/local/local-engram-repository.d.ts +3 -1
  3. package/dist/adapters/local/local-engram-repository.js +25 -0
  4. package/dist/adapters/mikoshi/client.d.ts +3 -1
  5. package/dist/adapters/mikoshi/client.js +33 -3
  6. package/dist/core/entities/engram.d.ts +28 -0
  7. package/dist/core/entities/engram.js +4 -0
  8. package/dist/core/ports/engram-repository.d.ts +17 -1
  9. package/dist/core/ports/mikoshi.d.ts +24 -0
  10. package/dist/core/ports/mikoshi.js +17 -0
  11. package/dist/core/sync/avatar.d.ts +121 -0
  12. package/dist/core/sync/avatar.js +352 -0
  13. package/dist/core/usecases/archive-cursor-update.d.ts +6 -0
  14. package/dist/core/usecases/archive-cursor-update.js +18 -0
  15. package/dist/core/usecases/archive-pending.d.ts +18 -1
  16. package/dist/core/usecases/archive-pending.js +37 -2
  17. package/dist/core/usecases/index.d.ts +3 -3
  18. package/dist/core/usecases/index.js +2 -2
  19. package/dist/core/usecases/memory-write.d.ts +5 -0
  20. package/dist/core/usecases/memory-write.js +26 -16
  21. package/dist/core/usecases/mikoshi-download.d.ts +5 -0
  22. package/dist/core/usecases/mikoshi-download.js +17 -1
  23. package/dist/core/usecases/mikoshi-pull.d.ts +26 -0
  24. package/dist/core/usecases/mikoshi-pull.js +47 -3
  25. package/dist/core/usecases/mikoshi-push.d.ts +129 -2
  26. package/dist/core/usecases/mikoshi-push.js +316 -11
  27. package/dist/interfaces/cli/commands/config.js +1 -1
  28. package/dist/interfaces/cli/commands/mikoshi.js +150 -13
  29. package/dist/interfaces/mcp/index.js +109 -38
  30. package/dist/shared/config.d.ts +2 -2
  31. package/dist/shared/config.js +3 -3
  32. package/dist/shared/engram-composer.d.ts +1 -1
  33. package/dist/shared/engram-composer.js +9 -5
  34. package/package.json +1 -1
@@ -142,7 +142,7 @@ export function registerConfigCommand(program) {
142
142
  // relic config distillation-batch-size [n]
143
143
  config
144
144
  .command("distillation-batch-size [n]")
145
- .description("Get or set the number of archive entries to distill at once (default: 30)")
145
+ .description("Get or set the number of archive entries to distill at once (default: 100)")
146
146
  .action(async (n) => {
147
147
  await ensureInitialized();
148
148
  const cfg = await loadConfig();
@@ -3,7 +3,7 @@ import { MikoshiApiClient } from "../../../adapters/mikoshi/client.js";
3
3
  import { printBlank, printDetail, printErrorDetail, printErrorLine, printLine } from "../output.js";
4
4
  import { startSpinner } from "../spinner.js";
5
5
  import { MikoshiStatus, MikoshiStatusEngramNotFoundError, MikoshiStatusCloudNotFoundError, } from "../../../core/usecases/mikoshi-status.js";
6
- import { MikoshiPush, MikoshiPushEngramNotFoundError, MikoshiPushPersonaConflictError, MikoshiPushPersonaHashError, } from "../../../core/usecases/mikoshi-push.js";
6
+ import { MikoshiPush, MikoshiPushEngramNotFoundError, MikoshiPushPersonaConflictError, MikoshiPushPersonaHashError, MikoshiPushAvatarInvalidMimeError, MikoshiPushAvatarHttpError, MikoshiPushAvatarPrivateHostError, MikoshiPushAvatarFetchError, MikoshiPushAvatarReadError, MikoshiPushAvatarTooLargeError, } from "../../../core/usecases/mikoshi-push.js";
7
7
  import { MikoshiDownload, MikoshiDownloadAlreadyExistsError, MikoshiDownloadCloudNotFoundError, MikoshiDownloadPersonaMissingError, } from "../../../core/usecases/mikoshi-download.js";
8
8
  import { MikoshiPull, MikoshiPullEngramNotFoundError, MikoshiPullCloudNotFoundError, MikoshiPullPersonaMissingError, } from "../../../core/usecases/mikoshi-pull.js";
9
9
  import { MikoshiMemorySync, MikoshiMemorySyncEngramNotFoundError, MikoshiMemorySyncCloudNotFoundError, MikoshiMemorySyncDecryptError, } from "../../../core/usecases/mikoshi-memory-sync.js";
@@ -152,33 +152,105 @@ export function registerMikoshiCommand(program) {
152
152
  const { result, apply } = await usecase.check(engramId);
153
153
  checkSpinner.stop();
154
154
  checkSpinner = undefined;
155
- if (result.outcome === "already_synced") {
155
+ const needsAvatarUpload = result.avatar?.outcome === "upload_required";
156
+ if (!apply) {
157
+ // already_synced と avatar 変更なし
156
158
  printLine(`✅ Persona already in sync (${result.engramName})`);
159
+ printAvatarSkipReason(result.avatar);
157
160
  }
158
161
  else {
159
- if (result.outcome === "create_required") {
160
- if (!opts.yes &&
161
- !(await confirm(`Engram "${engramId}" does not exist on Mikoshi. Create it? [y/N] `))) {
162
+ // Avatar URL drift の情報提示 (Phase 3):
163
+ // IDENTITY.md の diff が Avatar 行の値だけなら、ユーザーが
164
+ // 「自分は変更してないのに push_required と言われる」状態を
165
+ // 把握できるよう、確認プロンプトの前に理由を説明する。
166
+ if (result.avatarDrift) {
167
+ printErrorLine("⚠ IDENTITY.md differs only in the Avatar line.");
168
+ printErrorDetail(`local: ${result.avatarDrift.localValue}`);
169
+ printErrorDetail(`remote: ${result.avatarDrift.remoteValue}`);
170
+ printErrorLine(" This typically happens after 'relic mikoshi pull' rewrote the Avatar line to the R2 URL.");
171
+ printErrorLine(" Pushing will overwrite the remote IDENTITY.md with the local version.");
172
+ printBlank();
173
+ }
174
+ // Persona 確認 (already_synced のときは avatar-only なので persona 確認はスキップ)
175
+ if (result.outcome !== "already_synced" && !opts.yes) {
176
+ const prompt = result.avatarDrift
177
+ ? `Push the Avatar URL change to Mikoshi for "${engramId}"? [y/N] `
178
+ : result.outcome === "create_required"
179
+ ? `Engram "${engramId}" does not exist on Mikoshi. Create it? [y/N] `
180
+ : `Overwrite Mikoshi persona with the local Relic version for "${engramId}"? [y/N] `;
181
+ if (!(await confirm(prompt))) {
162
182
  printLine("Skipped.");
163
183
  return;
164
184
  }
165
185
  }
166
- else if (!opts.yes &&
167
- !(await confirm(`Overwrite Mikoshi persona with the local Relic version for "${engramId}"? [y/N] `))) {
168
- printLine("Skipped.");
169
- return;
186
+ // Avatar 確認
187
+ if (needsAvatarUpload && !opts.yes) {
188
+ const av = result.avatar;
189
+ printLine(`Avatar to upload for "${engramId}":`);
190
+ if (av.source === "url" && av.sourceUrl) {
191
+ printDetail(`url: ${av.sourceUrl}`);
192
+ }
193
+ if (av.localPath)
194
+ printDetail(`path: ${av.localPath}`);
195
+ if (av.localMimeType)
196
+ printDetail(`mime: ${av.localMimeType}`);
197
+ if (av.localSize !== undefined)
198
+ printDetail(`size: ${formatBytes(av.localSize)}`);
199
+ if (av.localHash)
200
+ printDetail(`hash: ${av.localHash}`);
201
+ if (!(await confirm(`Upload this avatar? [y/N] `))) {
202
+ printLine("Skipped.");
203
+ return;
204
+ }
170
205
  }
171
- applySpinner = startSpinner("Pushing persona to Mikoshi...");
172
- const applied = await apply();
206
+ const spinnerMessage = result.outcome === "already_synced"
207
+ ? result.avatar?.source === "url"
208
+ ? "Fetching avatar from URL and uploading to Mikoshi..."
209
+ : "Uploading avatar to Mikoshi..."
210
+ : result.avatar?.source === "url" && result.avatar?.outcome === "upload_required"
211
+ ? "Pushing persona, then fetching avatar from URL..."
212
+ : "Pushing persona to Mikoshi...";
213
+ applySpinner = startSpinner(spinnerMessage);
214
+ const applied = await apply({
215
+ onAvatarProgress: (stage) => {
216
+ if (!applySpinner)
217
+ return;
218
+ if (stage === "fetching") {
219
+ applySpinner.update("Fetching avatar from URL...");
220
+ return;
221
+ }
222
+ applySpinner.update("Uploading avatar to Mikoshi...");
223
+ },
224
+ });
173
225
  if (applied.action === "created") {
174
226
  applySpinner.stop(`✅ Created "${result.engramName}" on Mikoshi.`);
175
227
  applySpinner = undefined;
176
228
  printLine(`Cloud ID: ${applied.cloudEngramId}`);
177
229
  }
178
- else {
230
+ else if (applied.action === "updated") {
179
231
  applySpinner.stop(`✅ Persona updated for "${result.engramName}".`);
180
232
  applySpinner = undefined;
181
- printLine(`Hash: ${applied.newPersonaHash}`);
233
+ if (applied.newPersonaHash)
234
+ printLine(`Hash: ${applied.newPersonaHash}`);
235
+ }
236
+ else {
237
+ // avatar_only
238
+ applySpinner.stop(`✅ Avatar updated for "${result.engramName}".`);
239
+ applySpinner = undefined;
240
+ }
241
+ // Avatar の結果を最後に報告
242
+ if (applied.avatarAction === "uploaded" && applied.newAvatarUrl) {
243
+ printLine(`Avatar: ${applied.newAvatarUrl}`);
244
+ }
245
+ else if (applied.avatarAction === "skipped" && applied.avatarSkipReason) {
246
+ printAvatarSkipReason({ outcome: "skip", skipReason: applied.avatarSkipReason });
247
+ }
248
+ else if (applied.avatarAction === "failed") {
249
+ const failureLabel = applied.avatarFailureStage === "fetch"
250
+ ? "Avatar fetch failed"
251
+ : "Avatar upload failed";
252
+ printErrorLine(`⚠ ${failureLabel}: ${applied.avatarError?.message ?? "unknown error"}`);
253
+ printErrorLine(" The persona change was saved. Retry with 'relic mikoshi push' to upload the avatar.");
182
254
  }
183
255
  }
184
256
  }
@@ -202,6 +274,43 @@ export function registerMikoshiCommand(program) {
202
274
  printErrorLine("Re-run 'relic mikoshi status' to review the current state.");
203
275
  process.exit(1);
204
276
  }
277
+ if (err instanceof MikoshiPushAvatarInvalidMimeError) {
278
+ printError(`Error: Avatar format is not supported.`);
279
+ printErrorDetail(`Source: ${err.avatarPath}`);
280
+ printErrorDetail("Allowed: JPEG, PNG, WebP");
281
+ process.exit(1);
282
+ }
283
+ if (err instanceof MikoshiPushAvatarTooLargeError) {
284
+ printError(`Error: Avatar exceeds ${formatBytes(err.maxBytes)} (actual: ${formatBytes(err.actualBytes)}).`);
285
+ printErrorDetail(`Source: ${err.avatarPath}`);
286
+ process.exit(1);
287
+ }
288
+ if (err instanceof MikoshiPushAvatarReadError) {
289
+ printError(`Error: Failed to read avatar file.`);
290
+ printErrorDetail(`Path: ${err.avatarPath}`);
291
+ if (err.cause instanceof Error) {
292
+ printErrorDetail(err.cause.message);
293
+ }
294
+ process.exit(1);
295
+ }
296
+ if (err instanceof MikoshiPushAvatarHttpError) {
297
+ printError("Error: Avatar URL must use HTTPS.");
298
+ printErrorDetail(`URL: ${err.avatarUrl}`);
299
+ process.exit(1);
300
+ }
301
+ if (err instanceof MikoshiPushAvatarPrivateHostError) {
302
+ printError("Error: Avatar URL points to a private host.");
303
+ printErrorDetail(`URL: ${err.avatarUrl}`);
304
+ process.exit(1);
305
+ }
306
+ if (err instanceof MikoshiPushAvatarFetchError) {
307
+ printError("Error: Failed to fetch avatar from URL.");
308
+ printErrorDetail(`URL: ${err.avatarUrl}`);
309
+ if (err.cause instanceof Error) {
310
+ printErrorDetail(err.cause.message);
311
+ }
312
+ process.exit(1);
313
+ }
205
314
  if (err instanceof MikoshiApiError) {
206
315
  handleMikoshiApiError(err);
207
316
  }
@@ -250,6 +359,9 @@ export function registerMikoshiCommand(program) {
250
359
  const result = await download.execute(engramId);
251
360
  applySpinner.stop(`✅ Pulled "${result.engramName}" from Mikoshi.`);
252
361
  applySpinner = undefined;
362
+ if (result.rewrittenAvatarUrl) {
363
+ printDetail(`Avatar line rewritten to Mikoshi URL: ${result.rewrittenAvatarUrl}`);
364
+ }
253
365
  if (opts.sync) {
254
366
  const passphrase = await resolvePassphraseForSync();
255
367
  const syncUsecase = new MikoshiMemorySync(repo, client);
@@ -278,6 +390,9 @@ export function registerMikoshiCommand(program) {
278
390
  printDetail("SOUL.md — differs");
279
391
  if (diff.identityDiffers)
280
392
  printDetail("IDENTITY.md — differs");
393
+ if (diff.rewrittenAvatarUrl) {
394
+ printDetail(`Avatar line will be rewritten to Mikoshi URL: ${diff.rewrittenAvatarUrl}`);
395
+ }
281
396
  printBlank();
282
397
  // 確認プロンプト
283
398
  if (!opts.yes) {
@@ -415,6 +530,21 @@ export function registerMikoshiCommand(program) {
415
530
  }
416
531
  });
417
532
  }
533
+ function printAvatarSkipReason(avatar) {
534
+ if (!avatar || avatar.outcome !== "skip" || !avatar.skipReason)
535
+ return;
536
+ switch (avatar.skipReason) {
537
+ case "remote_avatar_url_matches_identity":
538
+ printLine("Avatar upload skipped: remote avatar URL already matches the local Avatar field.");
539
+ return;
540
+ case "source_url_unchanged":
541
+ printLine("Avatar upload skipped: avatar source URL is unchanged from the last successful push.");
542
+ return;
543
+ case "local_file_hash_unchanged":
544
+ printLine("Avatar upload skipped: local avatar file hash matches the last successful push.");
545
+ return;
546
+ }
547
+ }
418
548
  // ---------------------------------------------------------------------------
419
549
  // Shared error handler
420
550
  // ---------------------------------------------------------------------------
@@ -457,6 +587,13 @@ function statusLabel(status) {
457
587
  default: return status;
458
588
  }
459
589
  }
590
+ function formatBytes(bytes) {
591
+ if (bytes < 1024)
592
+ return `${bytes} B`;
593
+ if (bytes < 1024 * 1024)
594
+ return `${(bytes / 1024).toFixed(1)} KB`;
595
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
596
+ }
460
597
  function confirm(prompt) {
461
598
  const rl = createInterface({ input: process.stdin, output: process.stdout });
462
599
  return new Promise((resolve) => {
@@ -3,8 +3,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
5
  import { ArchiveSearch, ArchiveSearchEngramNotFoundError, } from "../../core/usecases/archive-search.js";
6
- import { ArchivePending, ArchivePendingEngramNotFoundError, } from "../../core/usecases/archive-pending.js";
7
- import { ArchiveCursorUpdate, } from "../../core/usecases/archive-cursor-update.js";
6
+ import { ArchivePending, ArchiveCursorCorruptedError, ArchivePendingEngramNotFoundError, } from "../../core/usecases/archive-pending.js";
7
+ import { ArchiveCursorUpdate, ArchiveCursorAdvanceOverflowError, } from "../../core/usecases/archive-cursor-update.js";
8
8
  import { MemoryWrite, MemoryWriteEngramNotFoundError, } from "../../core/usecases/memory-write.js";
9
9
  import { CreateEngram, EngramAlreadyExistsError, InvalidEngramIdError, } from "../../core/usecases/create-engram.js";
10
10
  import { DEFAULT_SOUL, DEFAULT_IDENTITY } from "../../shared/templates.js";
@@ -13,6 +13,63 @@ import { join } from "node:path";
13
13
  import { existsSync } from "node:fs";
14
14
  import { readFile, writeFile } from "node:fs/promises";
15
15
  import { resolveDistillationBatchSize, resolveEngramsPath, } from "../../shared/config.js";
16
+ async function appendLongTermAndUserProfile(engramsPath, id, long_term, user_profile) {
17
+ let longTermWritten = false;
18
+ if (long_term) {
19
+ const memoryMdPath = join(engramsPath, id, "MEMORY.md");
20
+ if (existsSync(memoryMdPath)) {
21
+ const existing = await readFile(memoryMdPath, "utf-8");
22
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
23
+ await writeFile(memoryMdPath, existing + separator + long_term + "\n", "utf-8");
24
+ }
25
+ else {
26
+ await writeFile(memoryMdPath, long_term + "\n", "utf-8");
27
+ }
28
+ longTermWritten = true;
29
+ }
30
+ let userProfileWritten = false;
31
+ if (user_profile) {
32
+ const userMdPath = join(engramsPath, id, "USER.md");
33
+ await writeFile(userMdPath, user_profile + "\n", "utf-8");
34
+ userProfileWritten = true;
35
+ }
36
+ return { longTermWritten, userProfileWritten };
37
+ }
38
+ function assertDateCoverage(writes, expectedDates, skippedDates) {
39
+ const writeDates = writes.map((write) => write.date);
40
+ const duplicateWriteDates = findDuplicates(writeDates);
41
+ if (duplicateWriteDates.length > 0) {
42
+ throw new Error(`Duplicate write dates are not allowed: ${duplicateWriteDates.join(", ")}`);
43
+ }
44
+ const duplicateSkippedDates = findDuplicates(skippedDates);
45
+ if (duplicateSkippedDates.length > 0) {
46
+ throw new Error(`Duplicate skipped_dates are not allowed: ${duplicateSkippedDates.join(", ")}`);
47
+ }
48
+ const overlappedDates = writeDates.filter((date) => skippedDates.includes(date));
49
+ if (overlappedDates.length > 0) {
50
+ throw new Error(`Dates cannot appear in both writes and skipped_dates: ${[...new Set(overlappedDates)].join(", ")}`);
51
+ }
52
+ const expectedSet = new Set(expectedDates);
53
+ const coveredDates = [...writeDates, ...skippedDates];
54
+ const unexpectedDates = coveredDates.filter((date) => !expectedSet.has(date));
55
+ if (unexpectedDates.length > 0) {
56
+ throw new Error(`Unexpected dates: ${[...new Set(unexpectedDates)].join(", ")}`);
57
+ }
58
+ const coveredSet = new Set(coveredDates);
59
+ const missingDates = expectedDates.filter((date) => !coveredSet.has(date));
60
+ if (missingDates.length > 0) {
61
+ throw new Error(`Missing dates: ${missingDates.join(", ")}`);
62
+ }
63
+ }
64
+ function findDuplicates(values) {
65
+ const counts = new Map();
66
+ for (const value of values) {
67
+ counts.set(value, (counts.get(value) ?? 0) + 1);
68
+ }
69
+ return [...counts.entries()]
70
+ .filter(([, count]) => count > 1)
71
+ .map(([value]) => value);
72
+ }
16
73
  const server = new McpServer({
17
74
  name: "relic",
18
75
  version: "0.1.0",
@@ -153,16 +210,34 @@ server.tool("relic_archive_pending", "Get un-distilled archive entries since the
153
210
  ],
154
211
  };
155
212
  }
156
- const header = `cursor: ${result.cursor} | total: ${result.total} | returned: ${result.entries.length} | remaining: ${result.remaining}`;
213
+ const dates = [...new Set(result.entries.map((entry) => entry.date).filter(Boolean))];
214
+ const header = [
215
+ `cursor: ${result.cursor}`,
216
+ `total: ${result.total}`,
217
+ `returned: ${result.entries.length}`,
218
+ `remaining: ${result.remaining}`,
219
+ `dates: ${dates.length > 0 ? dates.join(", ") : "unknown"}`,
220
+ ].join(" | ");
157
221
  const body = result.entries
158
- .map((e, i) => `[entry ${result.cursor + i + 1}]\n${e}`)
222
+ .map((entry, i) => {
223
+ const lines = [`[entry ${result.cursor + i + 1}]`];
224
+ if (entry.date) {
225
+ lines.push(`date: ${entry.date}`);
226
+ }
227
+ if (entry.summary) {
228
+ lines.push(`summary: ${entry.summary}`);
229
+ }
230
+ lines.push(entry.raw);
231
+ return lines.join("\n");
232
+ })
159
233
  .join("\n\n---\n\n");
160
234
  return {
161
235
  content: [{ type: "text", text: `${header}\n\n${body}` }],
162
236
  };
163
237
  }
164
238
  catch (err) {
165
- if (err instanceof ArchivePendingEngramNotFoundError) {
239
+ if (err instanceof ArchivePendingEngramNotFoundError ||
240
+ err instanceof ArchiveCursorCorruptedError) {
166
241
  return {
167
242
  content: [{ type: "text", text: err.message }],
168
243
  isError: true,
@@ -174,7 +249,19 @@ server.tool("relic_archive_pending", "Get un-distilled archive entries since the
174
249
  // --- relic_memory_write ---
175
250
  server.tool("relic_memory_write", "Write distilled memory to an Engram's memory file and advance the archive cursor. Call this after reviewing pending archive entries and distilling them into key insights.", {
176
251
  id: z.string().describe("Engram ID"),
177
- content: z.string().describe("Distilled memory content to write to memory/YYYY-MM-DD.md"),
252
+ writes: z
253
+ .array(z.object({
254
+ date: z.string().describe("Archive entry date for this memory file (YYYY-MM-DD)"),
255
+ content: z.string().describe("Distilled memory content for memory/YYYY-MM-DD.md"),
256
+ }))
257
+ .min(0)
258
+ .describe("Distilled memory writes grouped by actual archive dates"),
259
+ expected_dates: z
260
+ .array(z.string())
261
+ .describe("All archive dates that must be explicitly covered by writes or skipped_dates"),
262
+ skipped_dates: z
263
+ .array(z.string())
264
+ .describe("Archive dates intentionally skipped because they produced no durable memory"),
178
265
  count: z
179
266
  .number()
180
267
  .describe("Number of archive entries distilled (from relic_archive_pending returned count)"),
@@ -186,10 +273,6 @@ server.tool("relic_memory_write", "Write distilled memory to an Engram's memory
186
273
  .string()
187
274
  .optional()
188
275
  .describe("User profile updates to write to USER.md (preferences, tendencies, work style — about the human, not the project)"),
189
- date: z
190
- .string()
191
- .optional()
192
- .describe("Date for memory file (YYYY-MM-DD, default: today)"),
193
276
  path: z
194
277
  .string()
195
278
  .optional()
@@ -197,36 +280,21 @@ server.tool("relic_memory_write", "Write distilled memory to an Engram's memory
197
280
  }, async (args) => {
198
281
  const engramsPath = await resolveEngramsPath(args.path);
199
282
  try {
200
- // Step 1: Write daily memory
201
283
  const memoryWrite = new MemoryWrite(engramsPath);
202
- const writeResult = await memoryWrite.execute(args.id, args.content, args.date);
203
- // Step 2: Append to MEMORY.md if long_term is provided
204
- let longTermWritten = false;
205
- if (args.long_term) {
206
- const memoryMdPath = join(engramsPath, args.id, "MEMORY.md");
207
- if (existsSync(memoryMdPath)) {
208
- const existing = await readFile(memoryMdPath, "utf-8");
209
- const separator = existing.endsWith("\n") ? "\n" : "\n\n";
210
- await writeFile(memoryMdPath, existing + separator + args.long_term + "\n", "utf-8");
211
- }
212
- else {
213
- await writeFile(memoryMdPath, args.long_term + "\n", "utf-8");
214
- }
215
- longTermWritten = true;
216
- }
217
- // Step 2.5: Write USER.md if user_profile is provided
218
- let userProfileWritten = false;
219
- if (args.user_profile) {
220
- const userMdPath = join(engramsPath, args.id, "USER.md");
221
- await writeFile(userMdPath, args.user_profile + "\n", "utf-8");
222
- userProfileWritten = true;
223
- }
224
- // Step 3: Advance cursor by the number of distilled entries
284
+ assertDateCoverage(args.writes, args.expected_dates, args.skipped_dates);
285
+ const writeResults = await memoryWrite.executeBatch(args.id, args.writes);
286
+ const writeSummary = writeResults.length > 0
287
+ ? writeResults
288
+ .map((result) => `${result.date} (${result.appended ? "appended" : "created"})`)
289
+ .join(", ")
290
+ : "no files";
291
+ const { longTermWritten, userProfileWritten } = await appendLongTermAndUserProfile(engramsPath, args.id, args.long_term, args.user_profile);
225
292
  const cursorUpdate = new ArchiveCursorUpdate(engramsPath);
226
293
  const cursorResult = await cursorUpdate.execute(args.id, args.count);
227
- const parts = [
228
- `Memory written to ${writeResult.date} (${writeResult.appended ? "appended" : "created"}).`,
229
- ];
294
+ const parts = [`Memory written to ${writeSummary}.`];
295
+ if (args.skipped_dates.length > 0) {
296
+ parts.push(`Skipped dates: ${args.skipped_dates.join(", ")}.`);
297
+ }
230
298
  if (longTermWritten) {
231
299
  parts.push("Long-term memory (MEMORY.md) updated.");
232
300
  }
@@ -244,7 +312,10 @@ server.tool("relic_memory_write", "Write distilled memory to an Engram's memory
244
312
  };
245
313
  }
246
314
  catch (err) {
247
- if (err instanceof MemoryWriteEngramNotFoundError) {
315
+ if (err instanceof MemoryWriteEngramNotFoundError ||
316
+ err instanceof ArchiveCursorAdvanceOverflowError ||
317
+ err instanceof ArchiveCursorCorruptedError ||
318
+ err instanceof Error) {
248
319
  return {
249
320
  content: [{ type: "text", text: err.message }],
250
321
  isError: true,
@@ -17,7 +17,7 @@ export declare const RelicConfigSchema: z.ZodObject<{
17
17
  clawPath: z.ZodOptional<z.ZodString>;
18
18
  /** システムプロンプトに含める直近メモリエントリ数 (default: 2) */
19
19
  memoryWindowSize: z.ZodDefault<z.ZodNumber>;
20
- /** 一度の記憶蒸留で扱う archive エントリ数 (default: 30) */
20
+ /** 一度の記憶蒸留で扱う archive エントリ数 (default: 100) */
21
21
  distillationBatchSize: z.ZodDefault<z.ZodNumber>;
22
22
  }, "strip", z.ZodTypeAny, {
23
23
  memoryWindowSize: number;
@@ -80,7 +80,7 @@ export declare function resolveClawPath(cliOverride?: string): Promise<string |
80
80
  export declare function resolveMemoryWindowSize(): Promise<number>;
81
81
  /**
82
82
  * 記憶蒸留のバッチ件数を解決する。
83
- * 優先順位: config.distillationBatchSize > 30 (デフォルト)
83
+ * 優先順位: config.distillationBatchSize > 100 (デフォルト)
84
84
  */
85
85
  export declare function resolveDistillationBatchSize(): Promise<number>;
86
86
  /**
@@ -24,8 +24,8 @@ export const RelicConfigSchema = z.object({
24
24
  clawPath: z.string().optional(),
25
25
  /** システムプロンプトに含める直近メモリエントリ数 (default: 2) */
26
26
  memoryWindowSize: z.number().int().min(1).default(2),
27
- /** 一度の記憶蒸留で扱う archive エントリ数 (default: 30) */
28
- distillationBatchSize: z.number().int().min(1).default(30),
27
+ /** 一度の記憶蒸留で扱う archive エントリ数 (default: 100) */
28
+ distillationBatchSize: z.number().int().min(1).default(100),
29
29
  });
30
30
  /**
31
31
  * 設定ファイルを読み込む。存在しなければデフォルト値を返す。
@@ -163,7 +163,7 @@ export async function resolveMemoryWindowSize() {
163
163
  }
164
164
  /**
165
165
  * 記憶蒸留のバッチ件数を解決する。
166
- * 優先順位: config.distillationBatchSize > 30 (デフォルト)
166
+ * 優先順位: config.distillationBatchSize > 100 (デフォルト)
167
167
  */
168
168
  export async function resolveDistillationBatchSize() {
169
169
  await ensureInitialized();
@@ -9,7 +9,7 @@ export interface ComposeOptions {
9
9
  currentDate?: string;
10
10
  /** システムプロンプトに含める直近メモリエントリ数(デフォルト: 2) */
11
11
  memoryWindowSize?: number;
12
- /** 一度の記憶蒸留で扱う archive エントリ数(デフォルト: 30) */
12
+ /** 一度の記憶蒸留で扱う archive エントリ数(デフォルト: 100) */
13
13
  distillationBatchSize?: number;
14
14
  }
15
15
  /**
@@ -46,7 +46,7 @@ export function composeEngram(files, options) {
46
46
  */
47
47
  function composeRelicSection(meta, distillationBatchSize, currentDate) {
48
48
  const today = currentDate ?? new Date().toISOString().split("T")[0];
49
- const batchSize = distillationBatchSize ?? 30;
49
+ const batchSize = distillationBatchSize ?? 100;
50
50
  return `# Relic System
51
51
 
52
52
  - engramId: ${meta.id}
@@ -68,16 +68,20 @@ To recall past context, use \`relic_archive_search\` to search your archive by k
68
68
 
69
69
  When the user asks you to organize or distill memories:
70
70
  1. Call \`relic_archive_pending\` **once** to get un-distilled session entries (up to ${batchSize})
71
- 2. Review and distill them into:
72
- - **content**: key facts, decisions, and insights for \`memory/YYYY-MM-DD.md\`
71
+ 2. Review the archive entry dates and group the distilled memory by the **actual archive entry date**, not the distillation date
72
+ 3. Distill them into:
73
+ - **writes**: one item per date, each with \`date\` and \`content\`, for \`memory/YYYY-MM-DD.md\`
74
+ - **expected_dates**: every archive date present in the pending batch
75
+ - **skipped_dates**: dates that had pending entries but produced no durable memory worth saving
73
76
  - **long_term** (optional): only the most important, enduring facts for \`MEMORY.md\` (e.g. project architecture decisions, key constraints, hard rules)
74
77
  - **user_profile** (optional): full updated user profile for \`USER.md\` (preferences, tendencies, work style — about the human, not the project)
75
- 3. Call \`relic_memory_write\` with \`content\`, \`count\`, and optionally \`long_term\` and/or \`user_profile\`
76
- 4. If \`remaining > 0\`, inform the user how many entries are still pending — do NOT fetch more automatically
78
+ 4. Call \`relic_memory_write\` with \`writes\`, \`expected_dates\`, \`skipped_dates\`, \`count\`, and optionally \`long_term\` and/or \`user_profile\`
79
+ 5. If \`remaining > 0\`, inform the user how many entries are still pending — do NOT fetch more automatically
77
80
 
78
81
  **Important:**
79
82
  - Write distilled memories in the **same language the user is using** in the current conversation
80
83
  - Do NOT loop or repeat the distillation process — one round per user request
84
+ - Every archive date in the pending batch must appear in exactly one of \`writes.date\` or \`skipped_dates\`
81
85
  - \`long_term\` should be highly selective — only facts that matter across all future sessions (objective facts and rules)
82
86
  - \`user_profile\` is a complete replacement, not an append — write the full profile each time you update it (user tendencies, preferences, work style)
83
87
  - Distilled memories are loaded into your prompt on future sessions`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ectplsm/relic",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "PROJECT RELIC — Engram injection system for AI constructs",
5
5
  "license": "MIT",
6
6
  "repository": {