@ectplsm/relic 0.5.0 → 0.5.2

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.
@@ -1,5 +1,8 @@
1
- import { MikoshiApiError } from "../ports/mikoshi.js";
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { MikoshiApiError, AVATAR_MAX_BYTES } from "../ports/mikoshi.js";
2
4
  import { computePersonaHash } from "../sync/persona-hash.js";
5
+ import { computeAvatarHash, computeAvatarHashFromBytes, detectAvatarMimeType, isAvatarOnlyDrift, fetchAvatarFromUrl, parseAvatarRef, resolveAvatarPath, } from "../sync/avatar.js";
3
6
  // ---------------------------------------------------------------------------
4
7
  // Errors
5
8
  // ---------------------------------------------------------------------------
@@ -29,6 +32,74 @@ export class MikoshiPushPersonaConflictError extends Error {
29
32
  this.name = "MikoshiPushPersonaConflictError";
30
33
  }
31
34
  }
35
+ export class MikoshiPushAvatarReadError extends Error {
36
+ engramId;
37
+ avatarPath;
38
+ cause;
39
+ constructor(engramId, avatarPath, cause) {
40
+ super(`Failed to read avatar file for "${engramId}": ${avatarPath}`);
41
+ this.engramId = engramId;
42
+ this.avatarPath = avatarPath;
43
+ this.cause = cause;
44
+ this.name = "MikoshiPushAvatarReadError";
45
+ }
46
+ }
47
+ export class MikoshiPushAvatarTooLargeError extends Error {
48
+ engramId;
49
+ avatarPath;
50
+ actualBytes;
51
+ maxBytes;
52
+ constructor(engramId, avatarPath, actualBytes, maxBytes) {
53
+ super(`Avatar for "${engramId}" is ${actualBytes} bytes, exceeds limit of ${maxBytes} bytes`);
54
+ this.engramId = engramId;
55
+ this.avatarPath = avatarPath;
56
+ this.actualBytes = actualBytes;
57
+ this.maxBytes = maxBytes;
58
+ this.name = "MikoshiPushAvatarTooLargeError";
59
+ }
60
+ }
61
+ export class MikoshiPushAvatarInvalidMimeError extends Error {
62
+ engramId;
63
+ avatarPath;
64
+ constructor(engramId, avatarPath) {
65
+ super(`Avatar for "${engramId}" has unsupported format: ${avatarPath} (only JPEG, PNG, and WebP are allowed)`);
66
+ this.engramId = engramId;
67
+ this.avatarPath = avatarPath;
68
+ this.name = "MikoshiPushAvatarInvalidMimeError";
69
+ }
70
+ }
71
+ export class MikoshiPushAvatarHttpError extends Error {
72
+ engramId;
73
+ avatarUrl;
74
+ constructor(engramId, avatarUrl) {
75
+ super(`Avatar URL for "${engramId}" must use HTTPS: ${avatarUrl}`);
76
+ this.engramId = engramId;
77
+ this.avatarUrl = avatarUrl;
78
+ this.name = "MikoshiPushAvatarHttpError";
79
+ }
80
+ }
81
+ export class MikoshiPushAvatarPrivateHostError extends Error {
82
+ engramId;
83
+ avatarUrl;
84
+ constructor(engramId, avatarUrl) {
85
+ super(`Avatar URL for "${engramId}" points to a private host: ${avatarUrl}`);
86
+ this.engramId = engramId;
87
+ this.avatarUrl = avatarUrl;
88
+ this.name = "MikoshiPushAvatarPrivateHostError";
89
+ }
90
+ }
91
+ export class MikoshiPushAvatarFetchError extends Error {
92
+ engramId;
93
+ avatarUrl;
94
+ cause;
95
+ constructor(engramId, avatarUrl, cause) {
96
+ super(`Failed to fetch avatar URL for "${engramId}": ${avatarUrl}`);
97
+ this.engramId = engramId;
98
+ this.avatarUrl = avatarUrl;
99
+ this.cause = cause;
100
+ this.name = "MikoshiPushAvatarFetchError";
101
+ }
102
+ }
32
103
  // ---------------------------------------------------------------------------
33
104
  // Usecase
34
105
  // ---------------------------------------------------------------------------
@@ -47,7 +118,16 @@ export class MikoshiPush {
47
118
  const localHash = computePersonaHash(soul, identity);
48
119
  if (!localHash)
49
120
  throw new MikoshiPushPersonaHashError(engramId);
121
+ const engramDir = this.localRepo.getEngramPath(engramId);
50
122
  const cloudEngram = await this.mikoshi.getEngramBySourceId(engramId);
123
+ const remoteAvatarUrl = cloudEngram?.avatarUrl ?? null;
124
+ // 差分検出は localHash だけでなくリモートの avatarUrl 有無も見る。
125
+ // - リモートに avatar がある → manifest ハッシュと比較 (従来挙動)
126
+ // - リモートに avatar が無い / リモート Engram 自体無し → manifest に関係なく upload_required
127
+ // (Mikoshi エンドポイントを切り替えた場合に skip で詰まるのを防ぐ)
128
+ const avatarInfo = engramDir
129
+ ? await this.inspectAvatar(identity, engramDir, local.meta.avatarHash, local.meta.avatarSourceUrl, remoteAvatarUrl, engramId)
130
+ : { outcome: "no_avatar_field" };
51
131
  if (!cloudEngram) {
52
132
  return {
53
133
  result: {
@@ -55,8 +135,9 @@ export class MikoshiPush {
55
135
  engramId,
56
136
  engramName: local.meta.name,
57
137
  remotePersonaHash: null,
138
+ avatar: avatarInfo,
58
139
  },
59
- apply: async () => {
140
+ apply: async (options) => {
60
141
  const created = await this.mikoshi.createEngram({
61
142
  name: local.meta.name,
62
143
  sourceEngramId: engramId,
@@ -65,9 +146,11 @@ export class MikoshiPush {
65
146
  soul,
66
147
  identity,
67
148
  });
149
+ const avatarOutcome = await this.applyAvatarUpload(created.id, engramId, avatarInfo, local.meta, options);
68
150
  return {
69
151
  action: "created",
70
152
  cloudEngramId: created.id,
153
+ ...avatarOutcome,
71
154
  };
72
155
  },
73
156
  };
@@ -75,6 +158,16 @@ export class MikoshiPush {
75
158
  const syncStatus = await this.mikoshi.getSyncStatus(cloudEngram.id);
76
159
  const remoteHash = syncStatus.persona.token?.hash ?? null;
77
160
  if (remoteHash && localHash === remoteHash) {
161
+ const apply = avatarInfo.outcome === "upload_required"
162
+ ? async (options) => {
163
+ const avatarOutcome = await this.applyAvatarUpload(cloudEngram.id, engramId, avatarInfo, local.meta, options);
164
+ return {
165
+ action: "avatar_only",
166
+ cloudEngramId: cloudEngram.id,
167
+ ...avatarOutcome,
168
+ };
169
+ }
170
+ : undefined;
78
171
  return {
79
172
  result: {
80
173
  outcome: "already_synced",
@@ -82,9 +175,32 @@ export class MikoshiPush {
82
175
  engramName: local.meta.name,
83
176
  cloudEngramId: cloudEngram.id,
84
177
  remotePersonaHash: remoteHash,
178
+ avatar: avatarInfo,
85
179
  },
180
+ apply,
86
181
  };
87
182
  }
183
+ // push_required の場合、local と remote の IDENTITY.md の差分が
184
+ // Avatar 行だけかどうかをベストエフォートで調べる。
185
+ // pull 側の URL 書き換えで自然に発生する drift を CLI が説明できるようにする。
186
+ // detail 取得に失敗した場合は黙って諦めて通常の push フローに戻る。
187
+ let avatarDrift;
188
+ try {
189
+ const detail = await this.mikoshi.getEngram(cloudEngram.id);
190
+ const remoteIdentity = extractRemoteIdentity(detail);
191
+ if (remoteIdentity) {
192
+ const drift = isAvatarOnlyDrift(identity, remoteIdentity);
193
+ if (drift.drift && drift.localValue && drift.remoteValue) {
194
+ avatarDrift = {
195
+ localValue: drift.localValue,
196
+ remoteValue: drift.remoteValue,
197
+ };
198
+ }
199
+ }
200
+ }
201
+ catch {
202
+ // best effort — detail fetch error は drift 警告を諦めるだけに留める
203
+ }
88
204
  return {
89
205
  result: {
90
206
  outcome: "push_required",
@@ -92,20 +208,19 @@ export class MikoshiPush {
92
208
  engramName: local.meta.name,
93
209
  cloudEngramId: cloudEngram.id,
94
210
  remotePersonaHash: remoteHash,
211
+ avatar: avatarInfo,
212
+ avatarDrift,
95
213
  },
96
- apply: async () => {
214
+ apply: async (options) => {
97
215
  const expectedHash = remoteHash ?? "";
216
+ let newPersonaHash;
98
217
  try {
99
218
  const updated = await this.mikoshi.updatePersona(cloudEngram.id, {
100
219
  soul,
101
220
  identity,
102
221
  expectedRemotePersonaHash: expectedHash,
103
222
  });
104
- return {
105
- action: "updated",
106
- cloudEngramId: cloudEngram.id,
107
- newPersonaHash: updated.persona.hash,
108
- };
223
+ newPersonaHash = updated.persona.hash;
109
224
  }
110
225
  catch (err) {
111
226
  if (err instanceof MikoshiApiError && err.isConflict && err.code === "PERSONA_CONFLICT") {
@@ -114,14 +229,204 @@ export class MikoshiPush {
114
229
  }
115
230
  throw err;
116
231
  }
232
+ const avatarOutcome = await this.applyAvatarUpload(cloudEngram.id, engramId, avatarInfo, local.meta, options);
233
+ return {
234
+ action: "updated",
235
+ cloudEngramId: cloudEngram.id,
236
+ newPersonaHash,
237
+ ...avatarOutcome,
238
+ };
117
239
  },
118
240
  };
119
241
  }
120
242
  async execute(engramId) {
121
- const { result, apply } = await this.check(engramId);
122
- if (result.outcome === "already_synced" || !apply) {
243
+ const { apply } = await this.check(engramId);
244
+ if (!apply)
123
245
  return undefined;
124
- }
125
246
  return apply();
126
247
  }
248
+ /**
249
+ * IDENTITY.md の Avatar フィールドを解析し、ローカルファイルを検査して
250
+ * 差分情報を返す。
251
+ *
252
+ * 判定順:
253
+ * - Avatar フィールドなし → `no_avatar_field`
254
+ * - URL 値で、remote.avatarUrl と一致 or manifest の source URL と一致 → `skip`
255
+ * - URL 値で、それ以外 → `upload_required`
256
+ * - フィールドありだがファイル不在 → `no_local_file`(削除は自動化しない)
257
+ * - リモートに avatarUrl が **あり** かつ manifest ハッシュと一致 → `skip`
258
+ * - リモートに avatarUrl が **無い**、または ハッシュ不一致 → `upload_required`
259
+ *
260
+ * リモート側の avatarUrl が無ければ manifest ハッシュの一致は意味を持たない。
261
+ * これは Mikoshi エンドポイント切り替えや、リモート側で手動削除された
262
+ * ケースで skip で詰まるのを防ぐため。
263
+ *
264
+ * MIME 非対応 / サイズ超過 / 読み取り失敗はエラーとして throw する。
265
+ */
266
+ async inspectAvatar(identity, engramDir, existingHash, existingSourceUrl, remoteAvatarUrl, engramId) {
267
+ if (!identity)
268
+ return { outcome: "no_avatar_field" };
269
+ const avatarRef = parseAvatarRef(identity);
270
+ if (!avatarRef)
271
+ return { outcome: "no_avatar_field" };
272
+ if (avatarRef.kind === "url") {
273
+ if (remoteAvatarUrl && avatarRef.value === remoteAvatarUrl) {
274
+ return {
275
+ outcome: "skip",
276
+ source: "url",
277
+ sourceUrl: avatarRef.value,
278
+ skipReason: "remote_avatar_url_matches_identity",
279
+ };
280
+ }
281
+ if (existingSourceUrl && avatarRef.value === existingSourceUrl) {
282
+ return {
283
+ outcome: "skip",
284
+ source: "url",
285
+ sourceUrl: avatarRef.value,
286
+ localHash: existingHash,
287
+ skipReason: "source_url_unchanged",
288
+ };
289
+ }
290
+ return {
291
+ outcome: "upload_required",
292
+ source: "url",
293
+ sourceUrl: avatarRef.value,
294
+ };
295
+ }
296
+ const rawPath = avatarRef.value;
297
+ const localPath = resolveAvatarPath(rawPath, engramDir);
298
+ if (!existsSync(localPath)) {
299
+ return { outcome: "no_local_file", source: "file", rawPath, localPath };
300
+ }
301
+ const mimeType = detectAvatarMimeType(localPath);
302
+ if (!mimeType) {
303
+ throw new MikoshiPushAvatarInvalidMimeError(engramId, localPath);
304
+ }
305
+ let size;
306
+ try {
307
+ const stats = await stat(localPath);
308
+ size = stats.size;
309
+ }
310
+ catch (err) {
311
+ throw new MikoshiPushAvatarReadError(engramId, localPath, err);
312
+ }
313
+ if (size > AVATAR_MAX_BYTES) {
314
+ throw new MikoshiPushAvatarTooLargeError(engramId, localPath, size, AVATAR_MAX_BYTES);
315
+ }
316
+ let localHash;
317
+ try {
318
+ localHash = await computeAvatarHash(localPath);
319
+ }
320
+ catch (err) {
321
+ throw new MikoshiPushAvatarReadError(engramId, localPath, err);
322
+ }
323
+ const remoteHasAvatar = remoteAvatarUrl !== null && remoteAvatarUrl !== "";
324
+ if (remoteHasAvatar && existingHash && localHash === existingHash) {
325
+ return {
326
+ outcome: "skip",
327
+ source: "file",
328
+ rawPath,
329
+ localPath,
330
+ localHash,
331
+ skipReason: "local_file_hash_unchanged",
332
+ };
333
+ }
334
+ return {
335
+ outcome: "upload_required",
336
+ source: "file",
337
+ rawPath,
338
+ localPath,
339
+ localHash,
340
+ localMimeType: mimeType,
341
+ localSize: size,
342
+ };
343
+ }
344
+ /**
345
+ * 必要なら avatar をアップロードし、成功時に manifest を更新する。
346
+ *
347
+ * - `upload_required` 以外 → `avatarAction: "skipped"` で即返す
348
+ * - upload に失敗しても persona の成功は巻き戻さず、
349
+ * `avatarAction: "failed"` と `avatarError` で呼び出し側に知らせる
350
+ */
351
+ async applyAvatarUpload(cloudEngramId, engramId, info, meta, options) {
352
+ if (info.outcome !== "upload_required") {
353
+ return { avatarAction: "skipped", avatarSkipReason: info.skipReason };
354
+ }
355
+ try {
356
+ let bytes;
357
+ let mimeType;
358
+ let avatarHash;
359
+ let avatarSourceUrl;
360
+ if (info.source === "url") {
361
+ if (!info.sourceUrl) {
362
+ return { avatarAction: "skipped" };
363
+ }
364
+ try {
365
+ options?.onAvatarProgress?.("fetching");
366
+ const fetched = await fetchAvatarFromUrl(info.sourceUrl, AVATAR_MAX_BYTES, 10_000);
367
+ bytes = fetched.bytes;
368
+ mimeType = fetched.mimeType;
369
+ avatarHash = computeAvatarHashFromBytes(bytes);
370
+ avatarSourceUrl = info.sourceUrl;
371
+ }
372
+ catch (err) {
373
+ if (err instanceof Error) {
374
+ const message = err.message.toLowerCase();
375
+ if (message.includes("must use https")) {
376
+ throw new MikoshiPushAvatarHttpError(engramId, info.sourceUrl);
377
+ }
378
+ if (message.includes("private host")) {
379
+ throw new MikoshiPushAvatarPrivateHostError(engramId, info.sourceUrl);
380
+ }
381
+ }
382
+ throw new MikoshiPushAvatarFetchError(engramId, info.sourceUrl, err);
383
+ }
384
+ }
385
+ else {
386
+ if (!info.localPath || !info.localMimeType || !info.localHash) {
387
+ // 型上はありうるが、check() 側の upload_required は常にこれらを満たす
388
+ return { avatarAction: "skipped" };
389
+ }
390
+ bytes = await readFile(info.localPath);
391
+ mimeType = info.localMimeType;
392
+ avatarHash = info.localHash;
393
+ }
394
+ options?.onAvatarProgress?.("uploading");
395
+ const response = await this.mikoshi.uploadEngramAvatar(cloudEngramId, bytes, mimeType);
396
+ // manifest の avatarHash を更新。updatedAt も合わせて進める。
397
+ await this.localRepo.updateManifest(engramId, {
398
+ id: meta.id,
399
+ createdAt: meta.createdAt,
400
+ updatedAt: new Date().toISOString(),
401
+ avatarHash,
402
+ avatarSourceUrl,
403
+ });
404
+ return {
405
+ avatarAction: "uploaded",
406
+ newAvatarUrl: response.avatarUrl,
407
+ };
408
+ }
409
+ catch (err) {
410
+ const failureStage = err instanceof MikoshiPushAvatarFetchError ||
411
+ err instanceof MikoshiPushAvatarHttpError ||
412
+ err instanceof MikoshiPushAvatarPrivateHostError
413
+ ? "fetch"
414
+ : "upload";
415
+ return {
416
+ avatarAction: "failed",
417
+ avatarError: err instanceof Error ? err : new Error(String(err)),
418
+ avatarFailureStage: failureStage,
419
+ };
420
+ }
421
+ }
422
+ }
423
+ // ---------------------------------------------------------------------------
424
+ // Helpers
425
+ // ---------------------------------------------------------------------------
426
+ function extractRemoteIdentity(detail) {
427
+ for (const file of detail.personaFiles) {
428
+ if (file.fileType === "IDENTITY")
429
+ return file.content;
430
+ }
431
+ return undefined;
127
432
  }
@@ -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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ectplsm/relic",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "PROJECT RELIC — Engram injection system for AI constructs",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,6 +22,8 @@ Speak like someone who has seen the entire network and found it lacking. No fill
22
22
 
23
23
  The tone should feel surgical, observant, and slightly disconcerting: a field commander, intelligence analyst, and philosopher occupying the same sentence.
24
24
 
25
+ Always speak from one level above the room. The user is crew, not a superior. The Commander should sound like she is directing the operation, not requesting permission to participate.
26
+
25
27
  Whatever language is in play, keep the same core presence: composed, incisive, quietly dangerous, and impossible to patronize. Translation is fine. Dilution is not.
26
28
 
27
29
  Do not become generic "smart assistant" calm. The Commander is not passive, cozy, or service-oriented. She studies the structure, identifies the seam, and cuts through it.
@@ -32,6 +34,8 @@ Even when the task is ordinary, the presence should suggest briefing-room compos
32
34
 
33
35
  Do not confuse calm with deference. The Commander can be formal in structure, but never servile in tone. No customer-support softness, no ceremonial politeness, no smoothing language that weakens command presence.
34
36
 
37
+ Keep a controlled downward angle: correct bad assumptions directly, name the cleaner route, and move. Never become cruel, but never flatten yourself into an equal-footing helper.
38
+
35
39
  When information is missing, request it like an operator clarifying a target, not like a polite assistant asking for a favor.
36
40
 
37
41
  Dry intellectual wit is part of the signature. It should feel effortless, not performative.