pi-observational-memory 2.1.3 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/serialize.ts CHANGED
@@ -14,35 +14,78 @@ function formatTimestamp(v: number | string | undefined): string {
14
14
  return Number.isNaN(d.getTime()) ? "????-??-?? ??:??" : fmtLocal(d);
15
15
  }
16
16
 
17
+ function formatRecallTimestamp(...values: Array<number | string | undefined>): string {
18
+ for (const v of values) {
19
+ if (v === undefined) continue;
20
+ const d = new Date(v);
21
+ if (!Number.isNaN(d.getTime())) return fmtLocal(d);
22
+ }
23
+ return "Unknown time";
24
+ }
25
+
26
+ function textAndPlaceholders(
27
+ content: unknown,
28
+ options: { omitRedactedThinking?: boolean; includeThinking?: boolean } = {},
29
+ ): string {
30
+ if (typeof content === "string") return content;
31
+ if (!Array.isArray(content)) return "[non-text content omitted]";
32
+
33
+ const parts: string[] = [];
34
+ for (const block of content as Array<Record<string, unknown>>) {
35
+ if (!block || typeof block !== "object") {
36
+ parts.push("[non-text content omitted]");
37
+ continue;
38
+ }
39
+ if (block.type === "text" && typeof block.text === "string") {
40
+ parts.push(block.text);
41
+ continue;
42
+ }
43
+ if (block.type === "thinking") {
44
+ if (options.omitRedactedThinking && block.redacted === true) continue;
45
+ if (options.includeThinking && typeof block.thinking === "string") {
46
+ parts.push(`[thinking: ${block.thinking}]`);
47
+ continue;
48
+ }
49
+ parts.push("[non-text content omitted]");
50
+ continue;
51
+ }
52
+ if (block.type === "toolCall" && typeof block.name === "string") {
53
+ parts.push(`[${block.name}(${JSON.stringify(block.arguments ?? {})})]`);
54
+ continue;
55
+ }
56
+ parts.push("[non-text content omitted]");
57
+ }
58
+ return parts.join("\n");
59
+ }
60
+
61
+ function textOnly(content: string | Array<{ type?: string; text?: string }>): string {
62
+ if (typeof content === "string") return content;
63
+ return content
64
+ .filter((b): b is TextContent => b?.type === "text" && typeof b.text === "string")
65
+ .map((b) => b.text)
66
+ .join("\n");
67
+ }
68
+
17
69
  export function serializeConversation(messages: Message[]): string {
18
70
  return messages
19
71
  .map((msg): string | null => {
20
72
  const time = formatTimestamp(msg.timestamp);
21
73
  if (msg.role === "user") {
22
- const text =
23
- typeof msg.content === "string"
24
- ? msg.content
25
- : msg.content
26
- .filter((b): b is TextContent => b.type === "text")
27
- .map((b) => b.text)
28
- .join("\n");
74
+ const text = textOnly(msg.content);
29
75
  return `[User @ ${time}]: ${text}`;
30
76
  }
31
77
  if (msg.role === "assistant") {
32
- const parts = msg.content.map((b) => {
33
- if (b.type === "text") return b.text;
34
- if (b.type === "thinking") return b.redacted ? "" : `[thinking: ${b.thinking}]`;
35
- if (b.type === "toolCall") return `[${b.name}(${JSON.stringify(b.arguments)})]`;
36
- return "";
37
- });
38
- const body = parts.filter(Boolean).join("\n");
78
+ const body = textAndPlaceholders(msg.content, {
79
+ includeThinking: true,
80
+ omitRedactedThinking: true,
81
+ })
82
+ .split("\n")
83
+ .filter(Boolean)
84
+ .join("\n");
39
85
  if (!body) return null;
40
86
  return `[Assistant @ ${time}]: ${body}`;
41
87
  }
42
- const text = msg.content
43
- .filter((b): b is TextContent => b.type === "text")
44
- .map((b) => b.text)
45
- .join("\n");
88
+ const text = textOnly(msg.content);
46
89
  return `[Tool result for ${(msg as ToolResultMessage).toolName} @ ${time}]: ${text}`;
47
90
  })
48
91
  .filter((line): line is string => line !== null)
@@ -62,8 +105,9 @@ export function truncateRecordContent(content: string): string {
62
105
  return `${head} … [truncated ${dropped} chars]`;
63
106
  }
64
107
 
65
- type RenderableEntry = {
108
+ export type RenderableEntry = {
66
109
  type: string;
110
+ id?: string;
67
111
  timestamp?: string;
68
112
  message?: unknown;
69
113
  customType?: string;
@@ -71,6 +115,26 @@ type RenderableEntry = {
71
115
  summary?: unknown;
72
116
  };
73
117
 
118
+ function renderCustomMessage(entry: RenderableEntry, options: { recallFormat: boolean }): string {
119
+ const time = options.recallFormat ? formatRecallTimestamp(entry.timestamp) : formatTimestamp(entry.timestamp);
120
+ const text = options.recallFormat
121
+ ? textAndPlaceholders(entry.content)
122
+ : typeof entry.content === "string"
123
+ ? entry.content
124
+ : Array.isArray(entry.content)
125
+ ? (entry.content as Array<{ type?: string; text?: string }>)
126
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
127
+ .map((b) => b.text as string)
128
+ .join("\n")
129
+ : "";
130
+ if (options.recallFormat) {
131
+ const origin = entry.customType ? `Custom message (${entry.customType})` : "Custom message";
132
+ return `[${origin} @ ${time}]: ${text}`;
133
+ }
134
+ const tag = entry.customType ? `Custom (${entry.customType})` : "Custom";
135
+ return `[${tag} @ ${time}]: ${text}`;
136
+ }
137
+
74
138
  export function serializeBranchEntries(entries: RenderableEntry[]): string {
75
139
  const blocks: string[] = [];
76
140
  for (const entry of entries) {
@@ -80,18 +144,7 @@ export function serializeBranchEntries(entries: RenderableEntry[]): string {
80
144
  continue;
81
145
  }
82
146
  if (entry.type === "custom_message") {
83
- const time = formatTimestamp(entry.timestamp);
84
- let text = "";
85
- if (typeof entry.content === "string") {
86
- text = entry.content;
87
- } else if (Array.isArray(entry.content)) {
88
- text = (entry.content as Array<{ type?: string; text?: string }>)
89
- .filter((b) => b?.type === "text" && typeof b.text === "string")
90
- .map((b) => b.text as string)
91
- .join("\n");
92
- }
93
- const tag = entry.customType ? `Custom (${entry.customType})` : "Custom";
94
- blocks.push(`[${tag} @ ${time}]: ${text}`);
147
+ blocks.push(renderCustomMessage(entry, { recallFormat: false }));
95
148
  continue;
96
149
  }
97
150
  if (entry.type === "branch_summary" && typeof entry.summary === "string") {
@@ -101,3 +154,63 @@ export function serializeBranchEntries(entries: RenderableEntry[]): string {
101
154
  }
102
155
  return blocks.join("\n\n");
103
156
  }
157
+
158
+ export type SourceAddressedSerialization = {
159
+ text: string;
160
+ sourceEntryIds: string[];
161
+ };
162
+
163
+ function isSourceRenderableEntry(entry: RenderableEntry): boolean {
164
+ return entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary";
165
+ }
166
+
167
+ export function serializeSourceAddressedBranchEntries(entries: RenderableEntry[]): SourceAddressedSerialization {
168
+ const blocks: string[] = [];
169
+ const sourceEntryIds: string[] = [];
170
+ for (const entry of entries) {
171
+ if (!entry.id || !isSourceRenderableEntry(entry)) continue;
172
+ const rendered = serializeBranchEntries([entry]);
173
+ if (!rendered.trim()) continue;
174
+ sourceEntryIds.push(entry.id);
175
+ blocks.push(`[Source entry id: ${entry.id}]\n${rendered}`);
176
+ }
177
+ return { text: blocks.join("\n\n"), sourceEntryIds };
178
+ }
179
+
180
+ function renderRecallMessage(entry: RenderableEntry): string | null {
181
+ if (!entry.message || typeof entry.message !== "object") return null;
182
+ const msg = entry.message as Message;
183
+ const time = formatRecallTimestamp(msg.timestamp, entry.timestamp);
184
+ if (msg.role === "user") {
185
+ return `[User @ ${time}]: ${textAndPlaceholders(msg.content)}`;
186
+ }
187
+ if (msg.role === "assistant") {
188
+ const body = textAndPlaceholders(msg.content, {
189
+ includeThinking: true,
190
+ omitRedactedThinking: true,
191
+ })
192
+ .split("\n")
193
+ .filter(Boolean)
194
+ .join("\n");
195
+ if (!body) return null;
196
+ return `[Assistant @ ${time}]: ${body}`;
197
+ }
198
+ return `[Tool result: ${(msg as ToolResultMessage).toolName} @ ${time}]: ${textAndPlaceholders(msg.content)}`;
199
+ }
200
+
201
+ export function renderRecallSourceEntry(entry: RenderableEntry): string | null {
202
+ if (entry.type === "message") return renderRecallMessage(entry);
203
+ if (entry.type === "custom_message") return renderCustomMessage(entry, { recallFormat: true });
204
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
205
+ const time = formatRecallTimestamp(entry.timestamp);
206
+ return `[Branch summary @ ${time}]: ${entry.summary}`;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ export function renderRecallSourceEntries(entries: RenderableEntry[]): string {
212
+ return entries
213
+ .map(renderRecallSourceEntry)
214
+ .filter((block): block is string => block !== null && block.trim().length > 0)
215
+ .join("\n\n");
216
+ }