@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
@@ -0,0 +1,352 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { isAbsolute, resolve, extname } from "node:path";
4
+ import { isIP } from "node:net";
5
+ import { lookup } from "node:dns/promises";
6
+ /**
7
+ * Avatar path / hash utilities — engram-avatar-upload プラン準拠
8
+ *
9
+ * IDENTITY.md 内の `- **Avatar:** <path>` を検出し、
10
+ * 絶対パス / Engram 相対パスの両方を解決する。
11
+ * Phase 4 以降は URL 値も parse するが、既存 pull 互換のため
12
+ * `parseAvatarPath()` は従来どおり path のみ返す。
13
+ */
14
+ /** Avatar 行を検出する正規表現 */
15
+ const AVATAR_LINE_PATTERN = /^-\s+\*\*Avatar:\*\*\s*(.+)$/m;
16
+ /**
17
+ * 分割用の正規表現。
18
+ *
19
+ * group 1: `- **Avatar:** ` までの prefix(末尾の空白含む)
20
+ * group 2: Avatar の値部分(行末まで)
21
+ */
22
+ const AVATAR_LINE_PATTERN_SPLIT = /^(-\s+\*\*Avatar:\*\*\s*)(.+)$/m;
23
+ /** クライアントが許可する MIME タイプ */
24
+ const SUPPORTED_MIME = {
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".png": "image/png",
28
+ ".webp": "image/webp",
29
+ };
30
+ const SUPPORTED_MIME_SET = new Set(Object.values(SUPPORTED_MIME));
31
+ const MAX_REDIRECTS = 3;
32
+ /**
33
+ * IDENTITY.md から Avatar フィールドの生の値を抽出する。
34
+ *
35
+ * - マッチしなければ null
36
+ * - 値が URL(http://, https://)の場合は Mikoshi 管理外として null
37
+ * - 空白のみの値も null として扱う
38
+ */
39
+ export function parseAvatarPath(identity) {
40
+ const ref = parseAvatarRef(identity);
41
+ return ref?.kind === "path" ? ref.value : null;
42
+ }
43
+ /**
44
+ * IDENTITY.md から Avatar フィールドの参照先を抽出する。
45
+ *
46
+ * - マッチしなければ null
47
+ * - 空白のみの値も null
48
+ * - `http(s)://` は URL として返す
49
+ * - それ以外は path として返す
50
+ */
51
+ export function parseAvatarRef(identity) {
52
+ const match = identity.match(AVATAR_LINE_PATTERN);
53
+ if (!match)
54
+ return null;
55
+ const raw = match[1].trim();
56
+ if (raw.length === 0)
57
+ return null;
58
+ if (/^https?:\/\//i.test(raw)) {
59
+ return { kind: "url", value: raw };
60
+ }
61
+ return { kind: "path", value: raw };
62
+ }
63
+ /**
64
+ * Avatar パスを絶対パスに解決する。
65
+ *
66
+ * - 絶対パス → そのまま
67
+ * - 相対パス → Engram ディレクトリ基準で resolve
68
+ */
69
+ export function resolveAvatarPath(rawPath, engramDir) {
70
+ return isAbsolute(rawPath) ? rawPath : resolve(engramDir, rawPath);
71
+ }
72
+ /**
73
+ * ファイル内容の SHA-256 ハッシュを計算する。
74
+ *
75
+ * 出力形式: "sha256:<64 lowercase hex>"
76
+ *
77
+ * ストリーム処理なので 2MB 制限を超える巨大ファイルでも OOM しない。
78
+ */
79
+ export function computeAvatarHash(filePath) {
80
+ return new Promise((resolvePromise, rejectPromise) => {
81
+ const hash = createHash("sha256");
82
+ const stream = createReadStream(filePath);
83
+ stream.on("error", rejectPromise);
84
+ stream.on("data", (chunk) => hash.update(chunk));
85
+ stream.on("end", () => resolvePromise(`sha256:${hash.digest("hex")}`));
86
+ });
87
+ }
88
+ export function computeAvatarHashFromBytes(bytes) {
89
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
90
+ }
91
+ /**
92
+ * 拡張子から MIME タイプを判定する。
93
+ *
94
+ * 許可フォーマット: JPEG, PNG, WebP
95
+ * 対応外なら null(呼び出し側でエラーにする)
96
+ *
97
+ * バイナリ判定は Mikoshi 側の sharp に任せる方針。
98
+ * クライアントは軽量な事前バリデーションのみ。
99
+ */
100
+ export function detectAvatarMimeType(filePath) {
101
+ const ext = extname(filePath).toLowerCase();
102
+ return SUPPORTED_MIME[ext] ?? null;
103
+ }
104
+ /**
105
+ * レスポンス header / magic number の両方から MIME を判定する。
106
+ *
107
+ * 両方あって不一致なら null を返す。
108
+ */
109
+ export function detectAvatarMimeTypeFromBytes(bytes, headerMimeType) {
110
+ const normalizedHeader = normalizeHeaderMimeType(headerMimeType);
111
+ const magicMime = detectAvatarMimeTypeByMagic(bytes);
112
+ if (normalizedHeader && magicMime && normalizedHeader !== magicMime) {
113
+ return null;
114
+ }
115
+ return magicMime ?? normalizedHeader ?? null;
116
+ }
117
+ export function isHttpsUrl(raw) {
118
+ try {
119
+ return new URL(raw).protocol === "https:";
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ /**
126
+ * ホストが private / loopback / link-local なら true。
127
+ *
128
+ * hostname の場合は DNS lookup して、返ってきた全アドレスを検査する。
129
+ * DNS rebinding の完全対策ではない。事故防止用。
130
+ */
131
+ export async function isPrivateHost(url) {
132
+ const hostname = url.hostname;
133
+ if (!hostname)
134
+ return true;
135
+ if (isIpAddressPrivate(hostname)) {
136
+ return true;
137
+ }
138
+ if (isIP(hostname) !== 0) {
139
+ return false;
140
+ }
141
+ try {
142
+ const addresses = await lookup(hostname, { all: true });
143
+ if (addresses.length === 0)
144
+ return true;
145
+ return addresses.some((entry) => isIpAddressPrivate(entry.address));
146
+ }
147
+ catch {
148
+ return true;
149
+ }
150
+ }
151
+ export async function fetchAvatarFromUrl(url, maxBytes, timeoutMs) {
152
+ const controller = new AbortController();
153
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
154
+ try {
155
+ let currentUrl = url;
156
+ for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
157
+ if (!isHttpsUrl(currentUrl)) {
158
+ throw new Error(`Avatar URL must use HTTPS: ${currentUrl}`);
159
+ }
160
+ const parsed = new URL(currentUrl);
161
+ if (await isPrivateHost(parsed)) {
162
+ throw new Error(`Avatar URL points to a private host: ${currentUrl}`);
163
+ }
164
+ const response = await fetch(parsed, {
165
+ method: "GET",
166
+ redirect: "manual",
167
+ signal: controller.signal,
168
+ headers: {
169
+ Accept: "image/jpeg, image/png, image/webp",
170
+ },
171
+ });
172
+ if (isRedirectResponse(response.status)) {
173
+ const location = response.headers.get("location");
174
+ if (!location) {
175
+ throw new Error(`Avatar URL redirected without Location header: ${currentUrl}`);
176
+ }
177
+ if (redirectCount === MAX_REDIRECTS) {
178
+ throw new Error(`Avatar URL exceeded redirect limit (${MAX_REDIRECTS})`);
179
+ }
180
+ currentUrl = new URL(location, parsed).toString();
181
+ continue;
182
+ }
183
+ if (!response.ok) {
184
+ throw new Error(`Avatar URL fetch failed with HTTP ${response.status}`);
185
+ }
186
+ const contentLength = response.headers.get("content-length");
187
+ if (contentLength) {
188
+ const parsedLength = Number.parseInt(contentLength, 10);
189
+ if (Number.isFinite(parsedLength) && parsedLength > maxBytes) {
190
+ throw new Error(`Avatar URL content-length exceeds ${maxBytes} bytes (actual: ${parsedLength})`);
191
+ }
192
+ }
193
+ const bytes = await readResponseBody(response, maxBytes);
194
+ const mimeType = detectAvatarMimeTypeFromBytes(bytes, response.headers.get("content-type"));
195
+ if (!mimeType) {
196
+ throw new Error("Avatar URL returned an unsupported image format");
197
+ }
198
+ return { bytes, mimeType, finalUrl: currentUrl };
199
+ }
200
+ throw new Error(`Avatar URL exceeded redirect limit (${MAX_REDIRECTS})`);
201
+ }
202
+ finally {
203
+ clearTimeout(timer);
204
+ }
205
+ }
206
+ /**
207
+ * IDENTITY.md を Avatar 行の値を境に前後分離する。
208
+ *
209
+ * 書き換えと drift 検出の両方で使う基本操作。
210
+ */
211
+ export function splitAroundAvatar(identity) {
212
+ const match = AVATAR_LINE_PATTERN_SPLIT.exec(identity);
213
+ if (!match) {
214
+ return { before: identity, rawValue: null, after: "" };
215
+ }
216
+ const prefix = match[1];
217
+ const value = match[2];
218
+ const valueStart = match.index + prefix.length;
219
+ const valueEnd = valueStart + value.length;
220
+ return {
221
+ before: identity.slice(0, valueStart),
222
+ rawValue: value,
223
+ after: identity.slice(valueEnd),
224
+ };
225
+ }
226
+ /**
227
+ * IDENTITY.md の Avatar 行の値だけを差し替えて新しい文字列を返す。
228
+ *
229
+ * Avatar 行が存在しない場合は元の identity をそのまま返す
230
+ * (新しい Avatar 行は挿入しない — 保守的な挙動)。
231
+ */
232
+ export function rewriteAvatarValue(identity, newValue) {
233
+ const split = splitAroundAvatar(identity);
234
+ if (split.rawValue === null)
235
+ return identity;
236
+ return split.before + newValue + split.after;
237
+ }
238
+ /**
239
+ * 2つの IDENTITY.md の差分が Avatar 行の値だけかどうかを判定する。
240
+ *
241
+ * 判定条件:
242
+ * - 両方に Avatar 行が存在する
243
+ * - Avatar 行の前後 (`before` / `after`) が完全一致
244
+ * - Avatar 行の値だけ異なる
245
+ *
246
+ * PATH 前後の format 揺れ(改行数・空白数など)は
247
+ * `before` / `after` の文字列一致にそのまま反映されるため、
248
+ * 揺れがあれば avatar-only drift とは判定されない(false positive を避ける)。
249
+ */
250
+ export function isAvatarOnlyDrift(local, remote) {
251
+ const a = splitAroundAvatar(local);
252
+ const b = splitAroundAvatar(remote);
253
+ if (a.rawValue === null || b.rawValue === null) {
254
+ return { drift: false, localValue: null, remoteValue: null };
255
+ }
256
+ if (a.before === b.before && a.after === b.after && a.rawValue !== b.rawValue) {
257
+ return { drift: true, localValue: a.rawValue, remoteValue: b.rawValue };
258
+ }
259
+ return { drift: false, localValue: null, remoteValue: null };
260
+ }
261
+ function normalizeHeaderMimeType(headerMimeType) {
262
+ if (!headerMimeType)
263
+ return null;
264
+ const mimeType = headerMimeType.split(";")[0]?.trim().toLowerCase();
265
+ return mimeType && SUPPORTED_MIME_SET.has(mimeType) ? mimeType : null;
266
+ }
267
+ function detectAvatarMimeTypeByMagic(bytes) {
268
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
269
+ return "image/jpeg";
270
+ }
271
+ if (bytes.length >= 8 &&
272
+ bytes[0] === 0x89 &&
273
+ bytes[1] === 0x50 &&
274
+ bytes[2] === 0x4e &&
275
+ bytes[3] === 0x47 &&
276
+ bytes[4] === 0x0d &&
277
+ bytes[5] === 0x0a &&
278
+ bytes[6] === 0x1a &&
279
+ bytes[7] === 0x0a) {
280
+ return "image/png";
281
+ }
282
+ if (bytes.length >= 12 &&
283
+ bytes[0] === 0x52 &&
284
+ bytes[1] === 0x49 &&
285
+ bytes[2] === 0x46 &&
286
+ bytes[3] === 0x46 &&
287
+ bytes[8] === 0x57 &&
288
+ bytes[9] === 0x45 &&
289
+ bytes[10] === 0x42 &&
290
+ bytes[11] === 0x50) {
291
+ return "image/webp";
292
+ }
293
+ return null;
294
+ }
295
+ async function readResponseBody(response, maxBytes) {
296
+ const reader = response.body?.getReader();
297
+ if (!reader) {
298
+ throw new Error("Avatar URL response body is empty");
299
+ }
300
+ const chunks = [];
301
+ let totalBytes = 0;
302
+ while (true) {
303
+ const { done, value } = await reader.read();
304
+ if (done)
305
+ break;
306
+ if (!value)
307
+ continue;
308
+ totalBytes += value.byteLength;
309
+ if (totalBytes > maxBytes) {
310
+ throw new Error(`Avatar URL body exceeds ${maxBytes} bytes`);
311
+ }
312
+ chunks.push(value);
313
+ }
314
+ return Buffer.concat(chunks);
315
+ }
316
+ function isRedirectResponse(status) {
317
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
318
+ }
319
+ function isIpAddressPrivate(value) {
320
+ const family = isIP(value);
321
+ if (family === 4)
322
+ return isPrivateIpv4(value);
323
+ if (family === 6)
324
+ return isPrivateIpv6(value);
325
+ return false;
326
+ }
327
+ function isPrivateIpv4(value) {
328
+ const parts = value.split(".").map((part) => Number.parseInt(part, 10));
329
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
330
+ return false;
331
+ }
332
+ const [a, b] = parts;
333
+ if (a === 10)
334
+ return true;
335
+ if (a === 127)
336
+ return true;
337
+ if (a === 169 && b === 254)
338
+ return true;
339
+ if (a === 172 && b >= 16 && b <= 31)
340
+ return true;
341
+ if (a === 192 && b === 168)
342
+ return true;
343
+ return false;
344
+ }
345
+ function isPrivateIpv6(value) {
346
+ const normalized = value.toLowerCase();
347
+ if (normalized === "::1")
348
+ return true;
349
+ if (normalized.startsWith("fc") || normalized.startsWith("fd"))
350
+ return true;
351
+ return false;
352
+ }
@@ -16,6 +16,12 @@ export declare class ArchiveCursorUpdate {
16
16
  execute(engramId: string, count: number): Promise<ArchiveCursorUpdateResult>;
17
17
  private readCursor;
18
18
  }
19
+ export declare class ArchiveCursorAdvanceOverflowError extends Error {
20
+ readonly id: string;
21
+ readonly requestedCount: number;
22
+ readonly availablePending: number;
23
+ constructor(id: string, requestedCount: number, availablePending: number);
24
+ }
19
25
  export declare class ArchiveCursorUpdateEngramNotFoundError extends Error {
20
26
  constructor(id: string);
21
27
  }
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readFile, writeFile } from "node:fs/promises";
4
+ import { ArchivePending } from "./archive-pending.js";
4
5
  /**
5
6
  * ArchiveCursorUpdate — archive.cursor を指定件数分だけ進める
6
7
  *
@@ -19,6 +20,11 @@ export class ArchiveCursorUpdate {
19
20
  }
20
21
  const cursorPath = join(engramDir, "archive.cursor");
21
22
  const previousCursor = await this.readCursor(cursorPath);
23
+ const archivePending = new ArchivePending(this.engramsPath);
24
+ const pending = await archivePending.execute(engramId, Number.MAX_SAFE_INTEGER);
25
+ if (count > pending.entries.length) {
26
+ throw new ArchiveCursorAdvanceOverflowError(engramId, count, pending.entries.length);
27
+ }
22
28
  const newCursor = previousCursor + count;
23
29
  await writeFile(cursorPath, String(newCursor), "utf-8");
24
30
  return { previousCursor, newCursor };
@@ -36,6 +42,18 @@ export class ArchiveCursorUpdate {
36
42
  }
37
43
  }
38
44
  }
45
+ export class ArchiveCursorAdvanceOverflowError extends Error {
46
+ id;
47
+ requestedCount;
48
+ availablePending;
49
+ constructor(id, requestedCount, availablePending) {
50
+ super(`Engram "${id}" cursor advance overflow (${requestedCount} > ${availablePending}). Refusing to move archive.cursor.`);
51
+ this.id = id;
52
+ this.requestedCount = requestedCount;
53
+ this.availablePending = availablePending;
54
+ this.name = "ArchiveCursorAdvanceOverflowError";
55
+ }
56
+ }
39
57
  export class ArchiveCursorUpdateEngramNotFoundError extends Error {
40
58
  constructor(id) {
41
59
  super(`Engram "${id}" archive not found`);
@@ -1,6 +1,16 @@
1
+ export interface ArchivePendingEntry {
2
+ /** archive.md 先頭行から抽出した日付 */
3
+ date: string | null;
4
+ /** archive.md 先頭行の summary 部分 */
5
+ summary: string | null;
6
+ /** エントリ全文 */
7
+ raw: string;
8
+ /** 先頭行を除いた本文 */
9
+ body: string;
10
+ }
1
11
  export interface ArchivePendingResult {
2
12
  /** 未蒸留エントリ(cursor以降) */
3
- entries: string[];
13
+ entries: ArchivePendingEntry[];
4
14
  /** 現在のcursor位置 */
5
15
  cursor: number;
6
16
  /** archive内の総エントリ数 */
@@ -8,6 +18,12 @@ export interface ArchivePendingResult {
8
18
  /** 今回返さなかった残りの未蒸留エントリ数 */
9
19
  remaining: number;
10
20
  }
21
+ export declare class ArchiveCursorCorruptedError extends Error {
22
+ readonly id: string;
23
+ readonly cursor: number;
24
+ readonly total: number;
25
+ constructor(id: string, cursor: number, total: number);
26
+ }
11
27
  /**
12
28
  * ArchivePending — archive.md の未蒸留エントリを取得する
13
29
  *
@@ -20,6 +36,7 @@ export declare class ArchivePending {
20
36
  execute(engramId: string, limit?: number): Promise<ArchivePendingResult>;
21
37
  private readCursor;
22
38
  }
39
+ export declare function parseArchiveEntry(raw: string): ArchivePendingEntry;
23
40
  export declare class ArchivePendingEngramNotFoundError extends Error {
24
41
  constructor(id: string);
25
42
  }
@@ -2,7 +2,20 @@ import { join } from "node:path";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  const ENTRY_SEPARATOR = /\n---\n/;
5
- const DEFAULT_LIMIT = 30;
5
+ const DEFAULT_LIMIT = 100;
6
+ const ENTRY_HEADER_PATTERN = /^(\d{4}-\d{2}-\d{2}) \| ?(.*)$/;
7
+ export class ArchiveCursorCorruptedError extends Error {
8
+ id;
9
+ cursor;
10
+ total;
11
+ constructor(id, cursor, total) {
12
+ super(`Engram "${id}" archive cursor is invalid (${cursor} > ${total}). Repair archive.cursor before distilling.`);
13
+ this.id = id;
14
+ this.cursor = cursor;
15
+ this.total = total;
16
+ this.name = "ArchiveCursorCorruptedError";
17
+ }
18
+ }
6
19
  /**
7
20
  * ArchivePending — archive.md の未蒸留エントリを取得する
8
21
  *
@@ -29,11 +42,14 @@ export class ArchivePending {
29
42
  .map((e) => e.trim())
30
43
  .filter((e) => e.length > 0);
31
44
  const cursor = await this.readCursor(engramDir);
45
+ if (cursor > allEntries.length) {
46
+ throw new ArchiveCursorCorruptedError(engramId, cursor, allEntries.length);
47
+ }
32
48
  const pendingEntries = allEntries.slice(cursor);
33
49
  const effectiveLimit = limit ?? DEFAULT_LIMIT;
34
50
  const returned = pendingEntries.slice(0, effectiveLimit);
35
51
  return {
36
- entries: returned,
52
+ entries: returned.map(parseArchiveEntry),
37
53
  cursor,
38
54
  total: allEntries.length,
39
55
  remaining: pendingEntries.length - returned.length,
@@ -53,6 +69,25 @@ export class ArchivePending {
53
69
  }
54
70
  }
55
71
  }
72
+ export function parseArchiveEntry(raw) {
73
+ const trimmed = raw.trim();
74
+ const [header = "", ...rest] = trimmed.split("\n");
75
+ const match = header.match(ENTRY_HEADER_PATTERN);
76
+ if (!match) {
77
+ return {
78
+ date: null,
79
+ summary: null,
80
+ raw: trimmed,
81
+ body: trimmed,
82
+ };
83
+ }
84
+ return {
85
+ date: match[1],
86
+ summary: match[2] || null,
87
+ raw: trimmed,
88
+ body: rest.join("\n").trim(),
89
+ };
90
+ }
56
91
  export class ArchivePendingEngramNotFoundError extends Error {
57
92
  constructor(id) {
58
93
  super(`Engram "${id}" archive not found`);
@@ -5,11 +5,11 @@ export { MigrateEngrams, type MigrateEngramsResult, } from "./migrate-engrams.js
5
5
  export { RefreshSamples, type RefreshSamplesResult, } from "./refresh-samples.js";
6
6
  export { Inject, InjectEngramNotFoundError, InjectClawDirNotFoundError, InjectWorkspaceNotFoundError, type InjectPersonaFileDiff, type InjectPersonaDiffResult, type InjectResult, } from "./inject.js";
7
7
  export { Extract, WorkspaceNotFoundError, WorkspaceEmptyError, AlreadyExtractedError, type ExtractPersonaFileDiff, type ExtractPersonaDiffResult, type ExtractResult, } from "./extract.js";
8
- export { MemoryWrite, MemoryWriteEngramNotFoundError, type MemoryWriteResult, } from "./memory-write.js";
8
+ export { MemoryWrite, MemoryWriteEngramNotFoundError, type MemoryWriteBatchItem, type MemoryWriteResult, } from "./memory-write.js";
9
9
  export { ClawPull, ClawPullEngramNotFoundError, ClawPullWorkspaceNotFoundError, ClawPullPersonaMissingError, type ClawPullResult, type ClawPullDiff, } from "./claw-pull.js";
10
10
  export { Sync, SyncOpenclawDirNotFoundError, type SyncTarget, type SyncResult, type SyncInitialResult, } from "./sync.js";
11
- export { ArchivePending, ArchivePendingEngramNotFoundError, type ArchivePendingResult, } from "./archive-pending.js";
12
- export { ArchiveCursorUpdate, ArchiveCursorUpdateEngramNotFoundError, type ArchiveCursorUpdateResult, } from "./archive-cursor-update.js";
11
+ export { ArchivePending, ArchiveCursorCorruptedError, ArchivePendingEngramNotFoundError, type ArchivePendingEntry, type ArchivePendingResult, } from "./archive-pending.js";
12
+ export { ArchiveCursorUpdate, ArchiveCursorAdvanceOverflowError, ArchiveCursorUpdateEngramNotFoundError, type ArchiveCursorUpdateResult, } from "./archive-cursor-update.js";
13
13
  export { MikoshiStatus, MikoshiStatusEngramNotFoundError, MikoshiStatusCloudNotFoundError, type MikoshiStatusResult, } from "./mikoshi-status.js";
14
14
  export { MikoshiPush, MikoshiPushEngramNotFoundError, MikoshiPushPersonaHashError, MikoshiPushPersonaConflictError, type MikoshiPushResult, type MikoshiPushApplyResult, } from "./mikoshi-push.js";
15
15
  export { MikoshiDownload, MikoshiDownloadAlreadyExistsError, MikoshiDownloadCloudNotFoundError, MikoshiDownloadPersonaMissingError, type MikoshiDownloadResult, } from "./mikoshi-download.js";
@@ -8,8 +8,8 @@ export { Extract, WorkspaceNotFoundError, WorkspaceEmptyError, AlreadyExtractedE
8
8
  export { MemoryWrite, MemoryWriteEngramNotFoundError, } from "./memory-write.js";
9
9
  export { ClawPull, ClawPullEngramNotFoundError, ClawPullWorkspaceNotFoundError, ClawPullPersonaMissingError, } from "./claw-pull.js";
10
10
  export { Sync, SyncOpenclawDirNotFoundError, } from "./sync.js";
11
- export { ArchivePending, ArchivePendingEngramNotFoundError, } from "./archive-pending.js";
12
- export { ArchiveCursorUpdate, ArchiveCursorUpdateEngramNotFoundError, } from "./archive-cursor-update.js";
11
+ export { ArchivePending, ArchiveCursorCorruptedError, ArchivePendingEngramNotFoundError, } from "./archive-pending.js";
12
+ export { ArchiveCursorUpdate, ArchiveCursorAdvanceOverflowError, ArchiveCursorUpdateEngramNotFoundError, } from "./archive-cursor-update.js";
13
13
  export { MikoshiStatus, MikoshiStatusEngramNotFoundError, MikoshiStatusCloudNotFoundError, } from "./mikoshi-status.js";
14
14
  export { MikoshiPush, MikoshiPushEngramNotFoundError, MikoshiPushPersonaHashError, MikoshiPushPersonaConflictError, } from "./mikoshi-push.js";
15
15
  export { MikoshiDownload, MikoshiDownloadAlreadyExistsError, MikoshiDownloadCloudNotFoundError, MikoshiDownloadPersonaMissingError, } from "./mikoshi-download.js";
@@ -4,6 +4,10 @@ export interface MemoryWriteResult {
4
4
  appended: boolean;
5
5
  path: string;
6
6
  }
7
+ export interface MemoryWriteBatchItem {
8
+ date: string;
9
+ content: string;
10
+ }
7
11
  /**
8
12
  * MemoryWrite — Engramのメモリエントリに追記する
9
13
  *
@@ -14,6 +18,7 @@ export declare class MemoryWrite {
14
18
  private readonly engramsPath;
15
19
  constructor(engramsPath: string);
16
20
  execute(engramId: string, content: string, date?: string): Promise<MemoryWriteResult>;
21
+ executeBatch(engramId: string, writes: MemoryWriteBatchItem[]): Promise<MemoryWriteResult[]>;
17
22
  }
18
23
  export declare class MemoryWriteEngramNotFoundError extends Error {
19
24
  constructor(id: string);
@@ -14,29 +14,39 @@ export class MemoryWrite {
14
14
  }
15
15
  async execute(engramId, content, date) {
16
16
  const resolvedDate = date ?? new Date().toISOString().split("T")[0];
17
+ const [result] = await this.executeBatch(engramId, [
18
+ { date: resolvedDate, content },
19
+ ]);
20
+ return result;
21
+ }
22
+ async executeBatch(engramId, writes) {
17
23
  const engramDir = join(this.engramsPath, engramId);
18
24
  if (!existsSync(engramDir)) {
19
25
  throw new MemoryWriteEngramNotFoundError(engramId);
20
26
  }
21
27
  const memoryDir = join(engramDir, "memory");
22
28
  await mkdir(memoryDir, { recursive: true });
23
- const filePath = join(memoryDir, `${resolvedDate}.md`);
24
- let appended = false;
25
- if (existsSync(filePath)) {
26
- const existing = await readFile(filePath, "utf-8");
27
- const separator = existing.endsWith("\n") ? "\n" : "\n\n";
28
- await writeFile(filePath, existing + separator + content + "\n", "utf-8");
29
- appended = true;
30
- }
31
- else {
32
- await writeFile(filePath, content + "\n", "utf-8");
29
+ const results = [];
30
+ for (const write of writes) {
31
+ const filePath = join(memoryDir, `${write.date}.md`);
32
+ let appended = false;
33
+ if (existsSync(filePath)) {
34
+ const existing = await readFile(filePath, "utf-8");
35
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
36
+ await writeFile(filePath, existing + separator + write.content + "\n", "utf-8");
37
+ appended = true;
38
+ }
39
+ else {
40
+ await writeFile(filePath, write.content + "\n", "utf-8");
41
+ }
42
+ results.push({
43
+ engramId,
44
+ date: write.date,
45
+ appended,
46
+ path: filePath,
47
+ });
33
48
  }
34
- return {
35
- engramId,
36
- date: resolvedDate,
37
- appended,
38
- path: filePath,
39
- };
49
+ return results;
40
50
  }
41
51
  }
42
52
  export class MemoryWriteEngramNotFoundError extends Error {
@@ -4,6 +4,11 @@ export interface MikoshiDownloadResult {
4
4
  engramId: string;
5
5
  engramName: string;
6
6
  cloudEngramId: string;
7
+ /**
8
+ * リモートに `avatarUrl` があり、ローカルに書き込んだ IDENTITY.md の
9
+ * Avatar 行をその URL で書き換えた場合にセットされる。
10
+ */
11
+ rewrittenAvatarUrl?: string;
7
12
  }
8
13
  export declare class MikoshiDownloadAlreadyExistsError extends Error {
9
14
  readonly engramId: string;
@@ -1,3 +1,4 @@
1
+ import { rewriteAvatarValue } from "../sync/avatar.js";
1
2
  // ---------------------------------------------------------------------------
2
3
  // Errors
3
4
  // ---------------------------------------------------------------------------
@@ -55,6 +56,20 @@ export class MikoshiDownload {
55
56
  if (!soul || !identity) {
56
57
  throw new MikoshiDownloadPersonaMissingError(cloudEngram.id);
57
58
  }
59
+ // Avatar URL fallback (Phase 3):
60
+ // リモートに avatarUrl があれば、IDENTITY.md の Avatar 行をその URL に
61
+ // 書き換えてから保存する。新規ダウンロード時はローカルに画像が無いので
62
+ // URL 版のほうが Construct / Shell で表示できて実用的。
63
+ // Avatar 行が無ければ rewriteAvatarValue は no-op なので何も起こらない。
64
+ let identityToWrite = identity;
65
+ let rewrittenAvatarUrl;
66
+ if (detail.avatarUrl) {
67
+ const next = rewriteAvatarValue(identity, detail.avatarUrl);
68
+ if (next !== identity) {
69
+ identityToWrite = next;
70
+ rewrittenAvatarUrl = detail.avatarUrl;
71
+ }
72
+ }
58
73
  // 4. ローカルに新規作成
59
74
  const now = new Date().toISOString();
60
75
  await this.localRepo.save({
@@ -68,13 +83,14 @@ export class MikoshiDownload {
68
83
  },
69
84
  files: {
70
85
  soul,
71
- identity,
86
+ identity: identityToWrite,
72
87
  },
73
88
  });
74
89
  return {
75
90
  engramId,
76
91
  engramName: detail.name,
77
92
  cloudEngramId: cloudEngram.id,
93
+ rewrittenAvatarUrl,
78
94
  };
79
95
  }
80
96
  }