agent-sin 0.1.12 → 0.1.16

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 (97) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +2 -1
  3. package/builtin-skills/_shared/_todo_lib.py +290 -0
  4. package/builtin-skills/even-g2-setup/main.ts +896 -0
  5. package/builtin-skills/even-g2-setup/skill.yaml +133 -0
  6. package/builtin-skills/memo-delete/main.py +28 -107
  7. package/builtin-skills/memo-delete/skill.yaml +10 -21
  8. package/builtin-skills/memo-index/main.py +96 -64
  9. package/builtin-skills/memo-index/skill.yaml +4 -10
  10. package/builtin-skills/memo-list/main.py +126 -72
  11. package/builtin-skills/memo-list/skill.yaml +8 -14
  12. package/builtin-skills/memo-save/main.py +191 -25
  13. package/builtin-skills/memo-save/skill.yaml +29 -5
  14. package/builtin-skills/memo-search/main.py +38 -18
  15. package/builtin-skills/memo-vector-search/main.py +11 -6
  16. package/builtin-skills/nightly-topic-knowledge/_feedback_lib.py +391 -0
  17. package/builtin-skills/nightly-topic-knowledge/_topics_lib.py +415 -0
  18. package/builtin-skills/nightly-topic-knowledge/main.py +403 -0
  19. package/builtin-skills/nightly-topic-knowledge/skill.yaml +88 -0
  20. package/builtin-skills/schedule-add/main.py +26 -0
  21. package/builtin-skills/service-restart/main.ts +249 -0
  22. package/builtin-skills/service-restart/skill.yaml +49 -0
  23. package/builtin-skills/todo-add/main.py +3 -1
  24. package/builtin-skills/todo-delete/main.py +3 -1
  25. package/builtin-skills/todo-done/main.py +3 -1
  26. package/builtin-skills/todo-list/main.py +4 -1
  27. package/builtin-skills/todo-tick/main.py +3 -1
  28. package/builtin-skills/topic-knowledge-read/main.py +118 -0
  29. package/builtin-skills/topic-knowledge-read/skill.yaml +49 -0
  30. package/dist/builder/build-action-classifier.d.ts +18 -0
  31. package/dist/builder/build-action-classifier.js +82 -1
  32. package/dist/builder/build-flow.d.ts +33 -4
  33. package/dist/builder/build-flow.js +251 -89
  34. package/dist/builder/builder-session.d.ts +1 -1
  35. package/dist/builder/builder-session.js +112 -7
  36. package/dist/builder/conversation-router.d.ts +4 -2
  37. package/dist/builder/conversation-router.js +19 -2
  38. package/dist/cli/index.js +323 -20
  39. package/dist/core/ai-provider.d.ts +1 -0
  40. package/dist/core/ai-provider.js +8 -3
  41. package/dist/core/chat-engine.d.ts +9 -3
  42. package/dist/core/chat-engine.js +1263 -146
  43. package/dist/core/config.d.ts +4 -0
  44. package/dist/core/config.js +82 -0
  45. package/dist/core/daily-memory-promotion.d.ts +7 -0
  46. package/dist/core/daily-memory-promotion.js +596 -18
  47. package/dist/core/image-attachments.d.ts +31 -0
  48. package/dist/core/image-attachments.js +237 -0
  49. package/dist/core/logger.d.ts +2 -1
  50. package/dist/core/logger.js +77 -1
  51. package/dist/core/memo-migration.d.ts +3 -0
  52. package/dist/core/memo-migration.js +422 -0
  53. package/dist/core/native-modules.d.ts +24 -0
  54. package/dist/core/native-modules.js +99 -0
  55. package/dist/core/notifier.d.ts +8 -3
  56. package/dist/core/notifier.js +191 -17
  57. package/dist/core/obsidian-vault.d.ts +19 -0
  58. package/dist/core/obsidian-vault.js +477 -0
  59. package/dist/core/operating-model.d.ts +2 -0
  60. package/dist/core/operating-model.js +15 -0
  61. package/dist/core/output-writer.d.ts +3 -2
  62. package/dist/core/output-writer.js +108 -7
  63. package/dist/core/profile-memory.js +22 -1
  64. package/dist/core/runtime.d.ts +2 -0
  65. package/dist/core/runtime.js +9 -1
  66. package/dist/core/secrets.d.ts +4 -0
  67. package/dist/core/secrets.js +34 -0
  68. package/dist/core/skill-history.d.ts +44 -0
  69. package/dist/core/skill-history.js +329 -0
  70. package/dist/core/skill-registry.d.ts +5 -0
  71. package/dist/core/skill-registry.js +11 -0
  72. package/dist/discord/bot.d.ts +1 -0
  73. package/dist/discord/bot.js +181 -10
  74. package/dist/even-g2/gateway.d.ts +15 -0
  75. package/dist/even-g2/gateway.js +868 -0
  76. package/dist/runtimes/codex-app-server.d.ts +5 -1
  77. package/dist/runtimes/codex-app-server.js +147 -8
  78. package/dist/runtimes/python-runner.js +82 -0
  79. package/dist/runtimes/typescript-runner.js +13 -1
  80. package/dist/skills-sdk/types.d.ts +19 -4
  81. package/dist/telegram/bot.d.ts +1 -0
  82. package/dist/telegram/bot.js +115 -7
  83. package/package.json +3 -1
  84. package/templates/even-g2-agent/README.md +83 -0
  85. package/templates/even-g2-agent/app.json +20 -0
  86. package/templates/even-g2-agent/index.html +31 -0
  87. package/templates/even-g2-agent/package-lock.json +1836 -0
  88. package/templates/even-g2-agent/package.json +22 -0
  89. package/templates/even-g2-agent/scripts/qr-auto.mjs +182 -0
  90. package/templates/even-g2-agent/src/embedded-config.ts +4 -0
  91. package/templates/even-g2-agent/src/main.ts +539 -0
  92. package/templates/even-g2-agent/src/style.css +70 -0
  93. package/templates/even-g2-agent/tsconfig.json +11 -0
  94. package/templates/skill-python/main.py +20 -2
  95. package/templates/skill-python/skill.yaml +9 -0
  96. package/templates/skill-typescript/main.ts +40 -5
  97. package/templates/skill-typescript/skill.yaml +9 -0
@@ -7,8 +7,17 @@ import { appendProfileMemory, profileMemoryPath, readProfileMemoryFiles, } from
7
7
  import { l } from "./i18n.js";
8
8
  const MAX_DAILY_CHARS = 24000;
9
9
  const MAX_MEMORY_CHARS = 12000;
10
- const MAX_PROMOTION_ITEMS = 3;
10
+ const MAX_PROMOTION_ITEMS = 5;
11
11
  const MAX_ITEM_CHARS = 500;
12
+ const RECENT_TOPIC_DAYS = 7;
13
+ const MAX_RECENT_TOPIC_ITEMS = 50;
14
+ const MAX_RECENT_TOPIC_CHARS = 180;
15
+ const MAX_RECENT_TOPIC_DAILY_CHARS = 5000;
16
+ const MAX_RECENT_TOPIC_INPUT_CHARS = 36000;
17
+ const MAX_MEMORY_DIFF_CHARS = 12000;
18
+ const MAX_RECENT_TOPIC_USER_ENTRY_CHARS = 360;
19
+ const MAX_RECENT_TOPIC_ASSISTANT_ENTRY_CHARS = 420;
20
+ const MAX_RECENT_TOPIC_TOOL_ENTRY_CHARS = 260;
12
21
  export async function maybePromoteDailyMemory(config, options = {}) {
13
22
  let result;
14
23
  try {
@@ -68,9 +77,12 @@ export async function promoteDailyMemory(config, options = {}) {
68
77
  };
69
78
  }
70
79
  const profile = await readProfileMemoryFiles(config);
80
+ const memoryFile = profileMemoryPath(config, "memory");
81
+ const memoryBefore = await readTextIfExists(memoryFile);
82
+ const promotionDaily = dailyMemoryPromotionSource(daily);
71
83
  const response = await requestPromotionItems(config, {
72
84
  date,
73
- daily,
85
+ daily: promotionDaily,
74
86
  existingMemory: profile.memory,
75
87
  modelId: options.modelId || config.chat_model_id,
76
88
  });
@@ -88,6 +100,13 @@ export async function promoteDailyMemory(config, options = {}) {
88
100
  await appendProfileMemory(config, "memory", formatPromotedMemoryEntry(date, items), options.now || new Date());
89
101
  await consolidateMemoryFile(config, options.modelId || config.chat_model_id, options.eventSource || "chat");
90
102
  }
103
+ if (!options.dryRun) {
104
+ await refreshRecentTopicsSection(config, {
105
+ endDate: date,
106
+ modelId: options.modelId || config.chat_model_id,
107
+ eventSource: options.eventSource || "chat",
108
+ });
109
+ }
91
110
  if (!options.dryRun) {
92
111
  state.dates[date] = {
93
112
  hash,
@@ -97,6 +116,17 @@ export async function promoteDailyMemory(config, options = {}) {
97
116
  };
98
117
  await writePromotionState(config, state);
99
118
  }
119
+ if (!options.dryRun) {
120
+ await appendMemoryPromotionDiffLog(config, {
121
+ date,
122
+ file: memoryFile,
123
+ before: memoryBefore,
124
+ after: await readTextIfExists(memoryFile),
125
+ items,
126
+ status: items.length > 0 ? "promoted" : "reviewed",
127
+ eventSource: options.eventSource || "chat",
128
+ });
129
+ }
100
130
  return {
101
131
  status: items.length > 0 ? "promoted" : "reviewed",
102
132
  date,
@@ -105,6 +135,116 @@ export async function promoteDailyMemory(config, options = {}) {
105
135
  message: items.length > 0 ? `promoted ${items.length} item(s) from ${date}` : `no long-term memory items: ${date}`,
106
136
  };
107
137
  }
138
+ async function appendMemoryPromotionDiffLog(config, input) {
139
+ const diff = formatMemoryDiff(input.before, input.after);
140
+ await appendEventLog(config, {
141
+ level: "info",
142
+ source: input.eventSource,
143
+ event: "memory_promotion_diff",
144
+ message: diff
145
+ ? `memory.md diff recorded for ${input.date}`
146
+ : `memory.md unchanged for ${input.date}`,
147
+ details: {
148
+ date: input.date,
149
+ status: input.status,
150
+ file: input.file,
151
+ promoted_items: input.items,
152
+ changed: diff.length > 0,
153
+ diff,
154
+ },
155
+ });
156
+ }
157
+ function formatMemoryDiff(before, after) {
158
+ if (normalizeForCompare(before) === normalizeForCompare(after)) {
159
+ return "";
160
+ }
161
+ const raw = unifiedLineDiff(before, after);
162
+ return raw.length <= MAX_MEMORY_DIFF_CHARS
163
+ ? raw
164
+ : `${raw.slice(0, MAX_MEMORY_DIFF_CHARS)}\n... diff truncated ...`;
165
+ }
166
+ function unifiedLineDiff(before, after) {
167
+ const beforeLines = before.split(/\r?\n/);
168
+ const afterLines = after.split(/\r?\n/);
169
+ const operations = lineDiffOperations(beforeLines, afterLines);
170
+ return [
171
+ "--- memory.md before",
172
+ "+++ memory.md after",
173
+ "@@",
174
+ ...operations.map((op) => `${op.kind}${op.text}`),
175
+ ].join("\n");
176
+ }
177
+ function lineDiffOperations(beforeLines, afterLines) {
178
+ const rows = beforeLines.length + 1;
179
+ const cols = afterLines.length + 1;
180
+ if (rows * cols > 20000) {
181
+ return [
182
+ { kind: "-", text: `<${beforeLines.length} lines before>` },
183
+ { kind: "+", text: `<${afterLines.length} lines after>` },
184
+ ];
185
+ }
186
+ const dp = Array.from({ length: rows }, () => Array(cols).fill(0));
187
+ for (let i = beforeLines.length - 1; i >= 0; i -= 1) {
188
+ for (let j = afterLines.length - 1; j >= 0; j -= 1) {
189
+ dp[i][j] = beforeLines[i] === afterLines[j]
190
+ ? dp[i + 1][j + 1] + 1
191
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
192
+ }
193
+ }
194
+ const operations = [];
195
+ let i = 0;
196
+ let j = 0;
197
+ while (i < beforeLines.length && j < afterLines.length) {
198
+ if (beforeLines[i] === afterLines[j]) {
199
+ operations.push({ kind: " ", text: beforeLines[i] });
200
+ i += 1;
201
+ j += 1;
202
+ }
203
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
204
+ operations.push({ kind: "-", text: beforeLines[i] });
205
+ i += 1;
206
+ }
207
+ else {
208
+ operations.push({ kind: "+", text: afterLines[j] });
209
+ j += 1;
210
+ }
211
+ }
212
+ while (i < beforeLines.length) {
213
+ operations.push({ kind: "-", text: beforeLines[i] });
214
+ i += 1;
215
+ }
216
+ while (j < afterLines.length) {
217
+ operations.push({ kind: "+", text: afterLines[j] });
218
+ j += 1;
219
+ }
220
+ return compactDiffContext(operations);
221
+ }
222
+ function compactDiffContext(operations) {
223
+ const keep = new Set();
224
+ for (let i = 0; i < operations.length; i += 1) {
225
+ if (operations[i].kind === " ") {
226
+ continue;
227
+ }
228
+ for (let j = Math.max(0, i - 2); j <= Math.min(operations.length - 1, i + 2); j += 1) {
229
+ keep.add(j);
230
+ }
231
+ }
232
+ const compacted = [];
233
+ let skipped = false;
234
+ for (let i = 0; i < operations.length; i += 1) {
235
+ if (keep.has(i)) {
236
+ if (skipped) {
237
+ compacted.push({ kind: " ", text: "..." });
238
+ skipped = false;
239
+ }
240
+ compacted.push(operations[i]);
241
+ }
242
+ else {
243
+ skipped = true;
244
+ }
245
+ }
246
+ return compacted;
247
+ }
108
248
  export function parseDailyMemoryPromotionResponse(text) {
109
249
  const jsonText = extractJson(text);
110
250
  let parsed;
@@ -135,6 +275,26 @@ export function parseDailyMemoryPromotionResponse(text) {
135
275
  .filter(Boolean)
136
276
  .slice(0, MAX_PROMOTION_ITEMS);
137
277
  }
278
+ function dailyMemoryPromotionSource(daily) {
279
+ const note = [
280
+ "<!-- Promotion note:",
281
+ "Assistant and tool entries are included for context.",
282
+ "Promote a long-term fact only when it is supported by the target-date exchange, preferably by direct user wording.",
283
+ "Do not promote details that appear only because the assistant recalled or summarized an earlier date.",
284
+ "-->",
285
+ ].join("\n");
286
+ if (!daily.trim()) {
287
+ return daily;
288
+ }
289
+ const lines = daily.split(/\r?\n/);
290
+ const titleIndex = lines.findIndex((line) => /^#\s+\S/.test(line.trim()));
291
+ if (titleIndex < 0) {
292
+ return `${note}\n\n${daily}`;
293
+ }
294
+ const next = [...lines];
295
+ next.splice(titleIndex + 1, 0, "", note);
296
+ return next.join("\n");
297
+ }
138
298
  async function requestPromotionItems(config, input) {
139
299
  try {
140
300
  const response = await getAiProvider()(config, {
@@ -145,22 +305,29 @@ async function requestPromotionItems(config, input) {
145
305
  role: "system",
146
306
  content: [
147
307
  "You are Agent-Sin's long-term memory curator. memory.md is a long-term note used to understand the user deeply and keep conversations smooth.",
148
- "Promote only observations from daily conversation memory that directly help understand the user. Be strict; if unsure, output nothing. Maximum 3 items, zero is fine.",
308
+ "Promote observations and meta-level inferences from daily conversation memory that help understand the user across future conversations. Lean toward promoting when an observation is reasonably supported by user wording; skip only when truly unclear or noisy. Maximum 5 items, zero is fine.",
309
+ "Assistant responses may be used as context, but do not promote a fact whose only support is an assistant response, tool output, or system context.",
310
+ "A promoted fact must be grounded in the target-date exchange. Prefer direct user wording; assistant summaries are only supporting context.",
311
+ "If the user asks whether you remember a previous conversation, do not treat the assistant's recall as a new fact for the target date.",
312
+ "Casual conversation can be high-signal. Do not save raw small talk; extract the durable preference, recurring interest, concern, lifestyle pattern, decision criterion, or work/personal context it implies.",
313
+ "When support is light, avoid overclaiming. Prefer cautious wording such as 'appears interested in', 'is concerned about', 'often discusses', or 'may prefer' when that is more accurate than a hard fact.",
149
314
  "Write each item in the same language as the source daily-conversation.md content.",
150
315
  "Keep only items directly about the user:",
151
316
  "- The user's role, experience, interests, and personal context",
152
317
  "- Communication tendencies, preferred style, disliked expressions, and decision habits",
153
318
  "- Values, beliefs, and things the user considers important",
319
+ "- Lifestyle, health, learning, media, hobby, and product interests when useful for future conversations",
320
+ "- Meta-level patterns inferred from repeated or strongly worded casual chat",
154
321
  "- Notification/channel preferences, subscribed media, preferred formats",
155
322
  "- Stable interests or areas the user is not interested in",
156
323
  "Never keep operating rules, skill settings, or work logs:",
157
324
  "- Schedules, notification timing, notification destinations, filters, limits, or other skill behavior settings",
158
325
  "- Action rules tied to individual emails, files, or usernames",
159
326
  "- Recent task work, commits, fixes, or implementation steps",
160
- "- Same-day chat, impressions, status updates, progress, mood, health, or weather",
327
+ "- Raw same-day events, one-off impressions, transient status updates, or casual chat with no durable signal",
161
328
  "- Tool output, logs, code snippets, or URL lists",
162
329
  "- Content with the same meaning as existing memory.md",
163
- "- Secrets, API keys, tokens, sensitive finance/family information, or information identifying other people",
330
+ "- Secrets, API keys, tokens, detailed or sensitive health/finance/family information, or information identifying other people. Abstract sensitive topics to broad concerns or habits when useful.",
164
331
  "Output rules: each item must be one generic sentence or fact. Do not include dates, ongoing wording, or 'today I...' phrasing.",
165
332
  'Output JSON only: {"items":[{"text":"..."}]}. If nothing qualifies, return {"items":[]}.',
166
333
  ].join("\n"),
@@ -198,15 +365,383 @@ function formatPromotedMemoryEntry(date, items) {
198
365
  ...items.map((item) => `- ${item}`),
199
366
  ].join("\n");
200
367
  }
368
+ export async function refreshRecentTopicsSection(config, input) {
369
+ try {
370
+ const targetDate = input.endDate;
371
+ const targetDaily = await readTextIfExists(dailyMemoryFileForDate(config, targetDate));
372
+ const dailyHasContent = !!targetDaily.replace(/^# .+$/m, "").trim();
373
+ const file = profileMemoryPath(config, "memory");
374
+ const original = await readTextIfExists(file);
375
+ const existingTopics = parseDatedTopicsFromSection(original, targetDate);
376
+ const prunedExisting = pruneStaleTopics(existingTopics, targetDate, RECENT_TOPIC_DAYS);
377
+ let mergedTopics = prunedExisting;
378
+ let appended = 0;
379
+ if (dailyHasContent) {
380
+ const compacted = compactDailyMemoryForRecentTopics(targetDaily);
381
+ const newTopics = await requestRecentTopicsForDate(config, {
382
+ targetDate,
383
+ dailyContent: compacted,
384
+ existingTopics: prunedExisting,
385
+ modelId: input.modelId,
386
+ });
387
+ appended = newTopics.length;
388
+ mergedTopics = mergeDatedTopics(prunedExisting, newTopics, targetDate);
389
+ }
390
+ if (mergedTopics.length === 0 && existingTopics.length === 0) {
391
+ return;
392
+ }
393
+ const next = upsertRecentTopicsSection(original, targetDate, mergedTopics);
394
+ if (normalizeForCompare(next) === normalizeForCompare(original)) {
395
+ return;
396
+ }
397
+ await writeFile(file, next, "utf8");
398
+ await appendEventLog(config, {
399
+ level: "info",
400
+ source: input.eventSource,
401
+ event: "recent_topics_refreshed",
402
+ message: `recent topics refreshed through ${targetDate}`,
403
+ details: {
404
+ file,
405
+ end_date: targetDate,
406
+ items: mergedTopics.length,
407
+ appended,
408
+ pruned: existingTopics.length - prunedExisting.length,
409
+ },
410
+ });
411
+ }
412
+ catch (error) {
413
+ await appendEventLog(config, {
414
+ level: "warn",
415
+ source: input.eventSource,
416
+ event: "recent_topics_refresh_failed",
417
+ message: error instanceof Error ? error.message : String(error),
418
+ details: { end_date: input.endDate },
419
+ });
420
+ }
421
+ }
422
+ function parseDatedTopicsFromSection(original, fallbackDate) {
423
+ const lines = original.split(/\r?\n/);
424
+ const start = lines.findIndex((line) => recentTopicsHeading(line));
425
+ if (start < 0) {
426
+ return [];
427
+ }
428
+ let end = start + 1;
429
+ while (end < lines.length && !/^#{1,6}\s+\S/.test(lines[end].trim())) {
430
+ end += 1;
431
+ }
432
+ const body = lines.slice(start + 1, end);
433
+ const topics = [];
434
+ const seen = new Set();
435
+ for (const raw of body) {
436
+ const line = raw.replace(/^[-*・]\s*/, "").trim();
437
+ if (!line || line.startsWith("<!--"))
438
+ continue;
439
+ const dated = line.match(/^(\d{4}-\d{2}-\d{2})\s*[::]\s*(.+)$/);
440
+ const date = dated ? dated[1] : fallbackDate;
441
+ const text = cleanRecentTopic(dated ? dated[2] : line);
442
+ if (!text)
443
+ continue;
444
+ const key = normalizeForDedupe(text);
445
+ if (!key || seen.has(key))
446
+ continue;
447
+ seen.add(key);
448
+ topics.push({ date, text });
449
+ }
450
+ return topics;
451
+ }
452
+ function pruneStaleTopics(topics, targetDate, days) {
453
+ const cutoff = addDays(dateFromLocalDateString(targetDate), -(days - 1));
454
+ const cutoffKey = localDateString(cutoff);
455
+ return topics.filter((topic) => topic.date >= cutoffKey);
456
+ }
457
+ function mergeDatedTopics(existing, incoming, targetDate) {
458
+ const byKey = new Map();
459
+ const order = [];
460
+ for (const topic of existing) {
461
+ const key = normalizeForDedupe(topic.text);
462
+ if (!key || byKey.has(key))
463
+ continue;
464
+ byKey.set(key, topic);
465
+ order.push(key);
466
+ }
467
+ for (const topic of incoming) {
468
+ const key = normalizeForDedupe(topic.text);
469
+ if (!key || byKey.has(key))
470
+ continue;
471
+ byKey.set(key, { date: topic.date || targetDate, text: topic.text });
472
+ order.push(key);
473
+ }
474
+ const merged = order.map((key) => byKey.get(key)).filter(Boolean);
475
+ merged.sort((a, b) => (a.date === b.date ? 0 : a.date < b.date ? 1 : -1));
476
+ return merged.slice(0, MAX_RECENT_TOPIC_ITEMS);
477
+ }
478
+ async function requestRecentTopicsForDate(config, input) {
479
+ const existingBlock = input.existingTopics.length > 0
480
+ ? input.existingTopics.map((topic) => `- ${topic.date}: ${topic.text}`).join("\n")
481
+ : "(none)";
482
+ const response = await getAiProvider()(config, {
483
+ model_id: input.modelId,
484
+ temperature: 0,
485
+ messages: [
486
+ {
487
+ role: "system",
488
+ content: [
489
+ "You extract recent conversation topics for one day, to append into Agent-Sin's short-lived 'Recent 7-day topics' section inside memory.md.",
490
+ "Each output topic represents a topic that was active in the target-date conversation. Older topics from previous days are already kept by the system; do not restate them unless they were genuinely revisited today.",
491
+ "Capture what the user is working on, investigating, deciding, playing, tracking, or discussing — including work/project topics and notable personal, hobby, health, media, and life-context topics.",
492
+ "Keep concrete project, product, game, and skill names when useful. Merge duplicates within the day. Maximum 5 new topics.",
493
+ "Each topic: one concise sentence, in the same language as the source.",
494
+ "Do not include secrets, tokens, raw logs, private identifiers, URL lists, or sensitive facts about other people.",
495
+ "Avoid detailed private finance or family facts; if such a thread matters, keep it broad and non-identifying.",
496
+ "Do not restate stable preferences unless they were an active topic today.",
497
+ 'Output JSON only: {"topics":[{"text":"..."}]}. If nothing qualifies, return {"topics":[]}.',
498
+ ].join("\n"),
499
+ },
500
+ {
501
+ role: "user",
502
+ content: [
503
+ `Target date: ${input.targetDate}`,
504
+ "",
505
+ "<existing-recent-topics>",
506
+ existingBlock,
507
+ "</existing-recent-topics>",
508
+ "",
509
+ `<daily-conversation.md date="${input.targetDate}">`,
510
+ input.dailyContent,
511
+ `</daily-conversation.md>`,
512
+ ].join("\n").slice(0, MAX_RECENT_TOPIC_INPUT_CHARS),
513
+ },
514
+ ],
515
+ });
516
+ const texts = parseRecentTopicsResponse(response.text);
517
+ return texts.map((text) => ({ date: input.targetDate, text }));
518
+ }
519
+ function compactDailyMemoryForRecentTopics(raw) {
520
+ const parsed = parseDailyMemoryEntries(raw);
521
+ if (parsed.entries.length === 0) {
522
+ return clipAround(raw, MAX_RECENT_TOPIC_DAILY_CHARS);
523
+ }
524
+ const userEntries = parsed.entries
525
+ .filter((entry) => entry.role === "user")
526
+ .map((entry) => formatRecentTopicEntry(entry, MAX_RECENT_TOPIC_USER_ENTRY_CHARS))
527
+ .filter(Boolean);
528
+ const contextEntries = parsed.entries
529
+ .filter((entry) => entry.role === "assistant" || entry.role === "tool")
530
+ .map((entry) => formatRecentTopicEntry(entry, entry.role === "tool" ? MAX_RECENT_TOPIC_TOOL_ENTRY_CHARS : MAX_RECENT_TOPIC_ASSISTANT_ENTRY_CHARS))
531
+ .filter(Boolean);
532
+ const header = [
533
+ parsed.title,
534
+ "",
535
+ "<!-- Compacted for recent topic extraction. Direct user entries are prioritized; assistant/tool entries are included as context when budget allows. -->",
536
+ "",
537
+ ].join("\n");
538
+ const directSection = userEntries.length > 0
539
+ ? ["## Direct user entries", "", userEntries.join("\n\n")].join("\n")
540
+ : "## Direct user entries\n\n(no direct user entries)";
541
+ const contextSection = contextEntries.length > 0
542
+ ? ["## Assistant/tool context", "", contextEntries.join("\n\n")].join("\n")
543
+ : "";
544
+ const directOnly = `${header}${directSection}`.trimEnd();
545
+ if (directOnly.length >= MAX_RECENT_TOPIC_DAILY_CHARS || !contextSection) {
546
+ return `${clipAround(directOnly, MAX_RECENT_TOPIC_DAILY_CHARS)}\n`;
547
+ }
548
+ const remaining = MAX_RECENT_TOPIC_DAILY_CHARS - directOnly.length - 2;
549
+ return `${directOnly}\n\n${clipAround(contextSection, Math.max(0, remaining))}\n`;
550
+ }
551
+ function parseDailyMemoryEntries(raw) {
552
+ const lines = raw.split(/\r?\n/);
553
+ const title = lines.find((line) => /^#\s+\S/.test(line.trim())) || "# Daily conversation memory";
554
+ const entries = [];
555
+ let current = null;
556
+ for (const line of lines) {
557
+ const heading = line.match(/^##\s+(.+?)\s*$/);
558
+ if (heading) {
559
+ if (current) {
560
+ entries.push({
561
+ heading: current.heading,
562
+ role: dailyMemoryHeadingRole(current.heading),
563
+ body: current.body.join("\n").trim(),
564
+ });
565
+ }
566
+ current = { heading: heading[1].trim(), body: [] };
567
+ continue;
568
+ }
569
+ if (current) {
570
+ current.body.push(line);
571
+ }
572
+ }
573
+ if (current) {
574
+ entries.push({
575
+ heading: current.heading,
576
+ role: dailyMemoryHeadingRole(current.heading),
577
+ body: current.body.join("\n").trim(),
578
+ });
579
+ }
580
+ return { title, entries };
581
+ }
582
+ function dailyMemoryHeadingRole(heading) {
583
+ const match = heading.trim().match(/\b(system|user|assistant|tool)\s*$/);
584
+ return match?.[1] || "";
585
+ }
586
+ function formatRecentTopicEntry(entry, maxBodyChars) {
587
+ const body = normalizeRecentTopicEntryBody(entry.role, entry.body);
588
+ if (!body) {
589
+ return "";
590
+ }
591
+ return [`### ${entry.heading}`, clipAround(body, maxBodyChars)].join("\n\n");
592
+ }
593
+ function normalizeRecentTopicEntryBody(role, body) {
594
+ const trimmed = body.trim();
595
+ if (!trimmed) {
596
+ return "";
597
+ }
598
+ if (role === "assistant") {
599
+ const parsed = parseJsonObject(trimmed);
600
+ if (parsed) {
601
+ return normalizeAssistantEnvelope(parsed) || scrubRecentTopicText(trimmed);
602
+ }
603
+ }
604
+ if (role === "tool") {
605
+ const fenced = trimmed.match(/```skill-result\s*([\s\S]*?)```/i);
606
+ const parsed = parseJsonObject(fenced?.[1]?.trim() || trimmed);
607
+ if (parsed) {
608
+ const summary = parsed.summary;
609
+ return scrubRecentTopicText(typeof summary === "string" ? summary : trimmed);
610
+ }
611
+ }
612
+ return scrubRecentTopicText(trimmed);
613
+ }
614
+ function normalizeAssistantEnvelope(value) {
615
+ const parts = [];
616
+ if (typeof value.reply === "string" && value.reply.trim()) {
617
+ parts.push(value.reply.trim());
618
+ }
619
+ const calls = Array.isArray(value.skill_calls) ? value.skill_calls : [];
620
+ const callTexts = calls
621
+ .map((call) => {
622
+ if (!call || typeof call !== "object")
623
+ return "";
624
+ const record = call;
625
+ const id = typeof record.id === "string" ? record.id : "";
626
+ const args = record.args && typeof record.args === "object" ? record.args : {};
627
+ const text = typeof args.text === "string" ? args.text : "";
628
+ return [id, text].filter(Boolean).join(": ");
629
+ })
630
+ .filter(Boolean);
631
+ if (callTexts.length > 0) {
632
+ parts.push(`Skill calls: ${callTexts.join("; ")}`);
633
+ }
634
+ return scrubRecentTopicText(parts.join("\n\n"));
635
+ }
636
+ function parseJsonObject(text) {
637
+ try {
638
+ const parsed = JSON.parse(text);
639
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
640
+ ? parsed
641
+ : null;
642
+ }
643
+ catch {
644
+ return null;
645
+ }
646
+ }
647
+ function scrubRecentTopicText(text) {
648
+ return text
649
+ .replace(/\/Users\/[^\s)]+/g, "[local path]")
650
+ .replace(/\s+\n/g, "\n")
651
+ .replace(/\n{3,}/g, "\n\n")
652
+ .trim();
653
+ }
654
+ export function parseRecentTopicsResponse(text) {
655
+ const jsonText = extractJson(text);
656
+ let parsed;
657
+ try {
658
+ parsed = JSON.parse(jsonText);
659
+ }
660
+ catch {
661
+ return [];
662
+ }
663
+ const rawItems = Array.isArray(parsed)
664
+ ? parsed
665
+ : parsed && typeof parsed === "object" && Array.isArray(parsed.topics)
666
+ ? parsed.topics
667
+ : parsed && typeof parsed === "object" && Array.isArray(parsed.items)
668
+ ? parsed.items
669
+ : [];
670
+ const seen = new Set();
671
+ const topics = [];
672
+ for (const item of rawItems) {
673
+ const textValue = topicText(item);
674
+ const cleaned = cleanRecentTopic(textValue);
675
+ const key = normalizeForDedupe(cleaned);
676
+ if (!cleaned || !key || seen.has(key)) {
677
+ continue;
678
+ }
679
+ seen.add(key);
680
+ topics.push(cleaned);
681
+ if (topics.length >= MAX_RECENT_TOPIC_ITEMS) {
682
+ break;
683
+ }
684
+ }
685
+ return topics;
686
+ }
687
+ function topicText(item) {
688
+ if (typeof item === "string") {
689
+ return item;
690
+ }
691
+ if (item && typeof item === "object") {
692
+ const record = item;
693
+ const value = record.text || record.topic || record.summary || record.content;
694
+ return typeof value === "string" ? value : "";
695
+ }
696
+ return "";
697
+ }
698
+ function cleanRecentTopic(text) {
699
+ return text
700
+ .replace(/\s+/g, " ")
701
+ .replace(/^[-*・]\s*/, "")
702
+ .trim()
703
+ .slice(0, MAX_RECENT_TOPIC_CHARS);
704
+ }
705
+ function upsertRecentTopicsSection(original, endDate, topics) {
706
+ const base = removeRecentTopicsSection(original).replace(/\s+$/, "");
707
+ const section = formatRecentTopicsSection(endDate, topics);
708
+ return `${base}\n\n${section}\n`;
709
+ }
710
+ function formatRecentTopicsSection(endDate, topics) {
711
+ return [
712
+ l("## Recent 7-day topics", "## 直近1週間のトピック"),
713
+ "",
714
+ `<!-- ${l(`Short-lived context updated daily from the previous day's conversation memory through ${endDate}. Each line is dated; entries older than 7 days are evicted.`, `${endDate} までの前日の会話記録から日次で更新される短期トピック。各行は日付付き。7日より古い項目は自動で削除される。`)} -->`,
715
+ "",
716
+ ...topics.map((topic) => `- ${topic.date}: ${topic.text}`),
717
+ ].join("\n");
718
+ }
719
+ export function removeRecentTopicsSection(text) {
720
+ const lines = text.split(/\r?\n/);
721
+ const start = lines.findIndex((line) => recentTopicsHeading(line));
722
+ if (start < 0) {
723
+ return text;
724
+ }
725
+ let end = start + 1;
726
+ while (end < lines.length && !/^#{1,6}\s+\S/.test(lines[end].trim())) {
727
+ end += 1;
728
+ }
729
+ return [...lines.slice(0, start), ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
730
+ }
731
+ function recentTopicsHeading(line) {
732
+ return /^##\s+(?:Recent 7-day topics|直近1週間のトピック)\s*$/.test(line.trim());
733
+ }
201
734
  const MEMORY_CONSOLIDATE_INPUT_MAX = 32000;
202
735
  const MEMORY_HEADER_PATTERN = /^# memory\.md\s*$/im;
203
736
  async function consolidateMemoryFile(config, modelId, eventSource) {
204
737
  const file = profileMemoryPath(config, "memory");
205
738
  const original = await readTextIfExists(file);
206
- if (!original.trim())
739
+ const recentTopicsSection = extractRecentTopicsSection(original);
740
+ const originalWithoutRecentTopics = removeRecentTopicsSection(original);
741
+ if (!originalWithoutRecentTopics.trim())
207
742
  return;
208
- const headerBlock = extractHeaderBlock(original);
209
- const body = original.slice(headerBlock.length);
743
+ const headerBlock = extractHeaderBlock(originalWithoutRecentTopics);
744
+ const body = originalWithoutRecentTopics.slice(headerBlock.length);
210
745
  if (!body.trim())
211
746
  return;
212
747
  try {
@@ -217,18 +752,20 @@ async function consolidateMemoryFile(config, modelId, eventSource) {
217
752
  {
218
753
  role: "system",
219
754
  content: [
220
- "You organize Agent-Sin long-term memory. memory.md is a long-term note for understanding the user. Read the body, remove duplicates and stale items, and edit/overwrite existing items with newer information. Do not let it grow by appending only.",
755
+ "You organize Agent-Sin long-term memory. memory.md is a long-term note for understanding the user across many future conversations. Read the body, then actively reshape it: remove duplicates and stale items, edit/overwrite existing items with newer information, and reclassify content under better headings when the current structure no longer fits.",
756
+ "Treat this as a periodic inventory. You are encouraged to add new headings, rename headings, merge or split headings, move bullets across sections, and delete obsolete or low-signal lines. Do not let memory.md grow by pure appending, and do not freeze the existing structure just because it exists.",
221
757
  "Output rules:",
222
758
  "- Return only the Markdown body. No preface, afterword, or code fences.",
223
759
  "- Write content in the same language as the original memory.md body.",
224
- "- Preserve the existing heading structure when possible and normalize content under headings to one fact per bullet line starting with '-'. Do not create date headers or 'Auto promotion: ...' / '自動昇格: ...' headings.",
760
+ "- Normalize content under headings to one fact per bullet line starting with '-'. Do not create date headers or 'Auto promotion: ...' / '自動昇格: ...' headings.",
225
761
  "- Merge items with the same meaning. If new information updates old information, discard the old version and rewrite to the latest state.",
226
- "- Always keep observations directly tied to understanding the user: role, preferences, values, communication tendencies, media preferences, and interests.",
227
- "- Remove operating rules, schedules, skill behavior settings, specific sender names, filters, recent task work, progress, and casual chat.",
228
- "- Do not keep sensitive finance/family information or information that identifies other people.",
762
+ "- Prioritize keeping anything useful for understanding the user across future conversations: role, expertise, preferences, values, communication tendencies, decision habits, lifestyle patterns, broad health or self-improvement concerns, media preferences, recurring interests, stable dislikes, and long-running themes worth remembering even when they no longer come up daily.",
763
+ "- Overlap with the short-lived 'Recent 7-day topics' / '直近1週間のトピック' section is acceptable when the topic is also a durable long-term interest. The recent-topics section is managed separately, so focus this output on the long-term body only.",
764
+ "- Remove operating rules, schedules, skill behavior settings, specific sender names, filters, raw recent task work, transient progress, and casual chat that has no long-term signal.",
765
+ "- Do not keep detailed or sensitive health/finance/family information or information that identifies other people. Keep only broad, non-identifying concerns or habits when useful.",
229
766
  "- Do not invent content. Do not add facts not present in the source.",
230
767
  "- Each line must be a concise generic sentence or fact. Do not include dates or 'today I...' phrasing.",
231
- "- Keep the whole result to about 25 lines at most. Reduce further when possible.",
768
+ "- Aim for roughly 40 lines, and up to about 60 lines when there is genuinely durable signal worth preserving. Cut aggressively below this when items are weak or duplicative.",
232
769
  "Even if the target is empty or does not need much cleanup, return the minimal bullet list that preserves the current state.",
233
770
  ].join("\n"),
234
771
  },
@@ -242,10 +779,9 @@ async function consolidateMemoryFile(config, modelId, eventSource) {
242
779
  if (!cleaned)
243
780
  return;
244
781
  const next = `${headerBlock.replace(/\s+$/, "")}\n\n${cleaned}\n`;
245
- if (normalizeForCompare(next) === normalizeForCompare(original))
782
+ if (normalizeForCompare(next) === normalizeForCompare(originalWithoutRecentTopics))
246
783
  return;
247
- await writeFile(`${file}.bak`, original, "utf8");
248
- await writeFile(file, next, "utf8");
784
+ await writeFile(file, appendPreservedRecentTopicsSection(next, recentTopicsSection), "utf8");
249
785
  await appendEventLog(config, {
250
786
  level: "info",
251
787
  source: eventSource,
@@ -264,6 +800,25 @@ async function consolidateMemoryFile(config, modelId, eventSource) {
264
800
  });
265
801
  }
266
802
  }
803
+ function extractRecentTopicsSection(text) {
804
+ const lines = text.split(/\r?\n/);
805
+ const start = lines.findIndex((line) => recentTopicsHeading(line));
806
+ if (start < 0) {
807
+ return "";
808
+ }
809
+ let end = start + 1;
810
+ while (end < lines.length && !/^#{1,6}\s+\S/.test(lines[end].trim())) {
811
+ end += 1;
812
+ }
813
+ return lines.slice(start, end).join("\n").trim();
814
+ }
815
+ function appendPreservedRecentTopicsSection(body, recentTopicsSection) {
816
+ const recent = recentTopicsSection.trim();
817
+ if (!recent) {
818
+ return body;
819
+ }
820
+ return `${body.replace(/\s+$/, "")}\n\n${recent}\n`;
821
+ }
267
822
  function extractHeaderBlock(raw) {
268
823
  const match = MEMORY_HEADER_PATTERN.exec(raw);
269
824
  if (!match)
@@ -291,7 +846,7 @@ function sanitizeConsolidatedBody(text) {
291
846
  .split(/\r?\n/)
292
847
  .map((line) => line.trimEnd())
293
848
  .filter((line) => line.length > 0)
294
- .map((line) => (line.startsWith("- ") || line.startsWith("# ") ? line : line.replace(/^[-*・]?\s*/, "- ")));
849
+ .map((line) => (line.startsWith("- ") || /^#{1,6}\s+\S/.test(line) ? line : line.replace(/^[-*・]?\s*/, "- ")));
295
850
  return lines.join("\n").trim();
296
851
  }
297
852
  function isStructuredModelPayload(text) {
@@ -381,6 +936,15 @@ function localDateString(date) {
381
936
  const dd = String(date.getDate()).padStart(2, "0");
382
937
  return `${yyyy}-${MM}-${dd}`;
383
938
  }
939
+ function dateFromLocalDateString(value) {
940
+ const [yyyy, MM, dd] = value.split("-").map((part) => Number.parseInt(part, 10));
941
+ return new Date(yyyy, MM - 1, dd);
942
+ }
943
+ function addDays(date, days) {
944
+ const next = new Date(date);
945
+ next.setDate(next.getDate() + days);
946
+ return next;
947
+ }
384
948
  function dailyMemoryFileForDate(config, date) {
385
949
  const [yyyy, MM, dd] = date.split("-").map((part) => Number.parseInt(part, 10));
386
950
  return dailyConversationMemoryFile(config, new Date(yyyy, MM - 1, dd));
@@ -420,3 +984,17 @@ function clip(text, max) {
420
984
  }
421
985
  return `${text.slice(0, max)}\n\n... clipped ...`;
422
986
  }
987
+ function clipAround(text, max) {
988
+ if (max <= 0) {
989
+ return "";
990
+ }
991
+ if (text.length <= max) {
992
+ return text;
993
+ }
994
+ if (max < 40) {
995
+ return text.slice(0, max);
996
+ }
997
+ const marker = "\n\n... clipped middle ...\n\n";
998
+ const side = Math.floor((max - marker.length) / 2);
999
+ return `${text.slice(0, side)}${marker}${text.slice(text.length - side)}`;
1000
+ }