@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
@@ -5,7 +5,21 @@ export interface MikoshiPullDiff {
5
5
  soulDiffers: boolean;
6
6
  identityDiffers: boolean;
7
7
  remoteSoul: string;
8
+ /**
9
+ * 書き込み対象の IDENTITY.md。
10
+ *
11
+ * リモート IDENTITY.md の Avatar 行を
12
+ * `avatarUrl` に書き換えた後の最終形で保持する。
13
+ * 書き換えが発生しなかった場合は remote 原文と同一。
14
+ */
8
15
  remoteIdentity: string;
16
+ /**
17
+ * Avatar 行を Mikoshi 上の URL に書き換えたかどうか。
18
+ *
19
+ * 書き換えが発生したときのみ URL 文字列がセットされ、
20
+ * CLI が書き換え結果をユーザーへ通知するのに使う。
21
+ */
22
+ rewrittenAvatarUrl?: string;
9
23
  }
10
24
  export interface MikoshiPullResult {
11
25
  outcome: MikoshiPullOutcome;
@@ -41,4 +55,16 @@ export declare class MikoshiPull {
41
55
  result: MikoshiPullResult;
42
56
  apply?: () => Promise<void>;
43
57
  }>;
58
+ /**
59
+ * apply 対象となる IDENTITY.md を決定する。
60
+ *
61
+ * - リモートに `avatarUrl` が無い → remote をそのまま
62
+ * - ローカルに Avatar 行の path があり、そのファイルが存在する
63
+ * → ローカル画像が source of truth なので remote (path) を採用
64
+ * - それ以外(ローカル画像なし / ローカル値が URL / path だけあるがファイル欠如)
65
+ * → remote IDENTITY の Avatar 行を R2 URL に書き換え
66
+ *
67
+ * Avatar 行自体が無ければ rewrite は no-op で remote と等価になる。
68
+ */
69
+ private resolveAppliedIdentity;
44
70
  }
@@ -1,3 +1,5 @@
1
+ import { existsSync } from "node:fs";
2
+ import { parseAvatarRef, resolveAvatarPath, rewriteAvatarValue, } from "../sync/avatar.js";
1
3
  // ---------------------------------------------------------------------------
2
4
  // Errors
3
5
  // ---------------------------------------------------------------------------
@@ -58,9 +60,24 @@ export class MikoshiPull {
58
60
  if (!remoteSoul || !remoteIdentity) {
59
61
  throw new MikoshiPullPersonaMissingError(cloudEngram.id);
60
62
  }
63
+ // Avatar URL fallback (Phase 3):
64
+ // ローカルに有効な avatar 画像があれば remote IDENTITY をそのまま採用。
65
+ // ローカル画像が無い / ローカル値が URL の場合で remote.avatarUrl があれば
66
+ // apply 対象の IDENTITY を URL 版に差し替える。Avatar 行自体が無ければ
67
+ // rewriteAvatarValue は no-op なので何も追加しない。
68
+ const engramDir = this.localRepo.getEngramPath(engramId);
69
+ const appliedIdentity = this.resolveAppliedIdentity({
70
+ remoteIdentity,
71
+ remoteAvatarUrl: detail.avatarUrl,
72
+ localIdentity: local.files.identity,
73
+ engramDir,
74
+ });
75
+ const rewrittenAvatarUrl = detail.avatarUrl && appliedIdentity !== remoteIdentity
76
+ ? detail.avatarUrl
77
+ : undefined;
61
78
  // 4. 差分計算 (末尾空白の差異は無視)
62
79
  const soulDiffers = normalizeContent(local.files.soul) !== normalizeContent(remoteSoul);
63
- const identityDiffers = normalizeContent(local.files.identity) !== normalizeContent(remoteIdentity);
80
+ const identityDiffers = normalizeContent(local.files.identity) !== normalizeContent(appliedIdentity);
64
81
  if (!soulDiffers && !identityDiffers) {
65
82
  return {
66
83
  result: {
@@ -75,7 +92,8 @@ export class MikoshiPull {
75
92
  soulDiffers,
76
93
  identityDiffers,
77
94
  remoteSoul,
78
- remoteIdentity,
95
+ remoteIdentity: appliedIdentity,
96
+ rewrittenAvatarUrl,
79
97
  };
80
98
  // 5. apply クロージャ
81
99
  const apply = async () => {
@@ -86,7 +104,7 @@ export class MikoshiPull {
86
104
  if (soulDiffers)
87
105
  updatedFiles.soul = remoteSoul;
88
106
  if (identityDiffers)
89
- updatedFiles.identity = remoteIdentity;
107
+ updatedFiles.identity = appliedIdentity;
90
108
  await this.localRepo.save({
91
109
  meta: { ...fresh.meta, updatedAt: new Date().toISOString() },
92
110
  files: updatedFiles,
@@ -103,6 +121,32 @@ export class MikoshiPull {
103
121
  apply,
104
122
  };
105
123
  }
124
+ /**
125
+ * apply 対象となる IDENTITY.md を決定する。
126
+ *
127
+ * - リモートに `avatarUrl` が無い → remote をそのまま
128
+ * - ローカルに Avatar 行の path があり、そのファイルが存在する
129
+ * → ローカル画像が source of truth なので remote (path) を採用
130
+ * - それ以外(ローカル画像なし / ローカル値が URL / path だけあるがファイル欠如)
131
+ * → remote IDENTITY の Avatar 行を R2 URL に書き換え
132
+ *
133
+ * Avatar 行自体が無ければ rewrite は no-op で remote と等価になる。
134
+ */
135
+ resolveAppliedIdentity(args) {
136
+ const { remoteIdentity, remoteAvatarUrl, localIdentity, engramDir } = args;
137
+ if (!remoteAvatarUrl)
138
+ return remoteIdentity;
139
+ if (localIdentity && engramDir) {
140
+ const localRef = parseAvatarRef(localIdentity);
141
+ if (localRef?.kind === "path") {
142
+ const resolved = resolveAvatarPath(localRef.value, engramDir);
143
+ if (existsSync(resolved)) {
144
+ return remoteIdentity;
145
+ }
146
+ }
147
+ }
148
+ return rewriteAvatarValue(remoteIdentity, remoteAvatarUrl);
149
+ }
106
150
  }
107
151
  // ---------------------------------------------------------------------------
108
152
  // Helpers
@@ -1,6 +1,46 @@
1
1
  import type { EngramRepository } from "../ports/engram-repository.js";
2
2
  import type { MikoshiClient } from "../ports/mikoshi.js";
3
3
  export type MikoshiPushOutcome = "create_required" | "push_required" | "already_synced";
4
+ /**
5
+ * Avatar 差分判定の結果。
6
+ *
7
+ * - `no_avatar_field` : IDENTITY.md に Avatar フィールドが無い
8
+ * - `no_local_file` : Avatar フィールドはあるがローカルにファイルが無い(削除は自動化しない)
9
+ * - `skip` : ローカルハッシュがリモート (manifest) と一致
10
+ * - `upload_required` : 初回 or ハッシュ不一致。アップロード候補
11
+ */
12
+ export type MikoshiPushAvatarOutcome = "no_avatar_field" | "no_local_file" | "skip" | "upload_required";
13
+ export type MikoshiPushAvatarSkipReason = "remote_avatar_url_matches_identity" | "source_url_unchanged" | "local_file_hash_unchanged";
14
+ export interface MikoshiPushAvatarInfo {
15
+ outcome: MikoshiPushAvatarOutcome;
16
+ source?: "file" | "url";
17
+ /** 解決済みの絶対パス (outcome が no_avatar_field 以外で設定) */
18
+ localPath?: string;
19
+ /** IDENTITY.md に書かれていた生のパス値 */
20
+ rawPath?: string;
21
+ /** IDENTITY.md に書かれていた外部 URL */
22
+ sourceUrl?: string;
23
+ /** `sha256:<hex>` 形式。outcome が "skip" / "upload_required" のときのみ設定 */
24
+ localHash?: string;
25
+ /** `skip` のときの理由 */
26
+ skipReason?: MikoshiPushAvatarSkipReason;
27
+ /** バリデーション済み MIME。upload_required のときのみ設定 */
28
+ localMimeType?: string;
29
+ /** ファイルサイズ (bytes)。upload_required のときのみ設定 */
30
+ localSize?: number;
31
+ }
32
+ /**
33
+ * persona hash が不一致で、かつ local と remote の IDENTITY.md の差分が
34
+ * Avatar 行の値だけだった場合にセットされる。
35
+ *
36
+ * pull 側で R2 URL にローカルを書き換えたときに自然に発生するケース。
37
+ * CLI はこの値を使って「これは Avatar URL の drift ですよ」という
38
+ * 専用メッセージを出してから push 承認を取り直す。
39
+ */
40
+ export interface MikoshiPushAvatarDrift {
41
+ localValue: string;
42
+ remoteValue: string;
43
+ }
4
44
  export interface MikoshiPushResult {
5
45
  outcome: MikoshiPushOutcome;
6
46
  engramId: string;
@@ -8,11 +48,37 @@ export interface MikoshiPushResult {
8
48
  /** クラウド側の canonical ID (existing remote only) */
9
49
  cloudEngramId?: string;
10
50
  remotePersonaHash?: string | null;
51
+ /** Avatar 差分情報 (check 時点のスナップショット) */
52
+ avatar?: MikoshiPushAvatarInfo;
53
+ /** push_required のとき、IDENTITY.md の diff が Avatar 行だけなら設定 */
54
+ avatarDrift?: MikoshiPushAvatarDrift;
11
55
  }
56
+ export type MikoshiPushAvatarAction = "uploaded" | "skipped" | "failed";
57
+ export type MikoshiPushAvatarFailureStage = "fetch" | "upload";
58
+ export type MikoshiPushAvatarProgressStage = "fetching" | "uploading";
12
59
  export interface MikoshiPushApplyResult {
13
- action: "created" | "updated";
60
+ /**
61
+ * - `created` : 新規 Engram を作成した(avatar は同じ apply 内で追随)
62
+ * - `updated` : persona を上書きした
63
+ * - `avatar_only` : persona は同期済みだが avatar だけ更新した
64
+ */
65
+ action: "created" | "updated" | "avatar_only";
14
66
  cloudEngramId: string;
67
+ /** persona を更新したケースのみ設定 */
15
68
  newPersonaHash?: string;
69
+ /** Avatar アップロードの結果 */
70
+ avatarAction?: MikoshiPushAvatarAction;
71
+ /** アップロード成功時のみ設定される R2 上の URL */
72
+ newAvatarUrl?: string;
73
+ /** `avatarAction === "failed"` の場合に設定される失敗原因 */
74
+ avatarError?: Error;
75
+ /** `avatarAction === "failed"` の場合に設定される失敗段階 */
76
+ avatarFailureStage?: MikoshiPushAvatarFailureStage;
77
+ /** `avatarAction === "skipped"` の場合に設定される理由 */
78
+ avatarSkipReason?: MikoshiPushAvatarSkipReason;
79
+ }
80
+ export interface MikoshiPushApplyOptions {
81
+ onAvatarProgress?: (stage: MikoshiPushAvatarProgressStage) => void;
16
82
  }
17
83
  export declare class MikoshiPushEngramNotFoundError extends Error {
18
84
  readonly engramId: string;
@@ -27,13 +93,74 @@ export declare class MikoshiPushPersonaConflictError extends Error {
27
93
  readonly conflictRemoteHash?: string | undefined;
28
94
  constructor(engramId: string, conflictRemoteHash?: string | undefined);
29
95
  }
96
+ export declare class MikoshiPushAvatarReadError extends Error {
97
+ readonly engramId: string;
98
+ readonly avatarPath: string;
99
+ readonly cause?: unknown | undefined;
100
+ constructor(engramId: string, avatarPath: string, cause?: unknown | undefined);
101
+ }
102
+ export declare class MikoshiPushAvatarTooLargeError extends Error {
103
+ readonly engramId: string;
104
+ readonly avatarPath: string;
105
+ readonly actualBytes: number;
106
+ readonly maxBytes: number;
107
+ constructor(engramId: string, avatarPath: string, actualBytes: number, maxBytes: number);
108
+ }
109
+ export declare class MikoshiPushAvatarInvalidMimeError extends Error {
110
+ readonly engramId: string;
111
+ readonly avatarPath: string;
112
+ constructor(engramId: string, avatarPath: string);
113
+ }
114
+ export declare class MikoshiPushAvatarHttpError extends Error {
115
+ readonly engramId: string;
116
+ readonly avatarUrl: string;
117
+ constructor(engramId: string, avatarUrl: string);
118
+ }
119
+ export declare class MikoshiPushAvatarPrivateHostError extends Error {
120
+ readonly engramId: string;
121
+ readonly avatarUrl: string;
122
+ constructor(engramId: string, avatarUrl: string);
123
+ }
124
+ export declare class MikoshiPushAvatarFetchError extends Error {
125
+ readonly engramId: string;
126
+ readonly avatarUrl: string;
127
+ readonly cause?: unknown | undefined;
128
+ constructor(engramId: string, avatarUrl: string, cause?: unknown | undefined);
129
+ }
30
130
  export declare class MikoshiPush {
31
131
  private readonly localRepo;
32
132
  private readonly mikoshi;
33
133
  constructor(localRepo: EngramRepository, mikoshi: MikoshiClient);
34
134
  check(engramId: string): Promise<{
35
135
  result: MikoshiPushResult;
36
- apply?: () => Promise<MikoshiPushApplyResult>;
136
+ apply?: (options?: MikoshiPushApplyOptions) => Promise<MikoshiPushApplyResult>;
37
137
  }>;
38
138
  execute(engramId: string): Promise<MikoshiPushApplyResult | undefined>;
139
+ /**
140
+ * IDENTITY.md の Avatar フィールドを解析し、ローカルファイルを検査して
141
+ * 差分情報を返す。
142
+ *
143
+ * 判定順:
144
+ * - Avatar フィールドなし → `no_avatar_field`
145
+ * - URL 値で、remote.avatarUrl と一致 or manifest の source URL と一致 → `skip`
146
+ * - URL 値で、それ以外 → `upload_required`
147
+ * - フィールドありだがファイル不在 → `no_local_file`(削除は自動化しない)
148
+ * - リモートに avatarUrl が **あり** かつ manifest ハッシュと一致 → `skip`
149
+ * - リモートに avatarUrl が **無い**、または ハッシュ不一致 → `upload_required`
150
+ *
151
+ * リモート側の avatarUrl が無ければ manifest ハッシュの一致は意味を持たない。
152
+ * これは Mikoshi エンドポイント切り替えや、リモート側で手動削除された
153
+ * ケースで skip で詰まるのを防ぐため。
154
+ *
155
+ * MIME 非対応 / サイズ超過 / 読み取り失敗はエラーとして throw する。
156
+ */
157
+ private inspectAvatar;
158
+ /**
159
+ * 必要なら avatar をアップロードし、成功時に manifest を更新する。
160
+ *
161
+ * - `upload_required` 以外 → `avatarAction: "skipped"` で即返す
162
+ * - upload に失敗しても persona の成功は巻き戻さず、
163
+ * `avatarAction: "failed"` と `avatarError` で呼び出し側に知らせる
164
+ */
165
+ private applyAvatarUpload;
39
166
  }
@@ -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
  }