agent-sin 0.1.12 → 0.1.15

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 +66 -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 +568 -14
  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,7 +305,10 @@ 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 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.",
149
312
  "Write each item in the same language as the source daily-conversation.md content.",
150
313
  "Keep only items directly about the user:",
151
314
  "- The user's role, experience, interests, and personal context",
@@ -198,15 +361,382 @@ function formatPromotedMemoryEntry(date, items) {
198
361
  ...items.map((item) => `- ${item}`),
199
362
  ].join("\n");
200
363
  }
364
+ export async function refreshRecentTopicsSection(config, input) {
365
+ try {
366
+ const targetDate = input.endDate;
367
+ const targetDaily = await readTextIfExists(dailyMemoryFileForDate(config, targetDate));
368
+ const dailyHasContent = !!targetDaily.replace(/^# .+$/m, "").trim();
369
+ const file = profileMemoryPath(config, "memory");
370
+ const original = await readTextIfExists(file);
371
+ const existingTopics = parseDatedTopicsFromSection(original, targetDate);
372
+ const prunedExisting = pruneStaleTopics(existingTopics, targetDate, RECENT_TOPIC_DAYS);
373
+ let mergedTopics = prunedExisting;
374
+ let appended = 0;
375
+ if (dailyHasContent) {
376
+ const compacted = compactDailyMemoryForRecentTopics(targetDaily);
377
+ const newTopics = await requestRecentTopicsForDate(config, {
378
+ targetDate,
379
+ dailyContent: compacted,
380
+ existingTopics: prunedExisting,
381
+ modelId: input.modelId,
382
+ });
383
+ appended = newTopics.length;
384
+ mergedTopics = mergeDatedTopics(prunedExisting, newTopics, targetDate);
385
+ }
386
+ if (mergedTopics.length === 0 && existingTopics.length === 0) {
387
+ return;
388
+ }
389
+ const next = upsertRecentTopicsSection(original, targetDate, mergedTopics);
390
+ if (normalizeForCompare(next) === normalizeForCompare(original)) {
391
+ return;
392
+ }
393
+ await writeFile(file, next, "utf8");
394
+ await appendEventLog(config, {
395
+ level: "info",
396
+ source: input.eventSource,
397
+ event: "recent_topics_refreshed",
398
+ message: `recent topics refreshed through ${targetDate}`,
399
+ details: {
400
+ file,
401
+ end_date: targetDate,
402
+ items: mergedTopics.length,
403
+ appended,
404
+ pruned: existingTopics.length - prunedExisting.length,
405
+ },
406
+ });
407
+ }
408
+ catch (error) {
409
+ await appendEventLog(config, {
410
+ level: "warn",
411
+ source: input.eventSource,
412
+ event: "recent_topics_refresh_failed",
413
+ message: error instanceof Error ? error.message : String(error),
414
+ details: { end_date: input.endDate },
415
+ });
416
+ }
417
+ }
418
+ function parseDatedTopicsFromSection(original, fallbackDate) {
419
+ const lines = original.split(/\r?\n/);
420
+ const start = lines.findIndex((line) => recentTopicsHeading(line));
421
+ if (start < 0) {
422
+ return [];
423
+ }
424
+ let end = start + 1;
425
+ while (end < lines.length && !/^#{1,6}\s+\S/.test(lines[end].trim())) {
426
+ end += 1;
427
+ }
428
+ const body = lines.slice(start + 1, end);
429
+ const topics = [];
430
+ const seen = new Set();
431
+ for (const raw of body) {
432
+ const line = raw.replace(/^[-*・]\s*/, "").trim();
433
+ if (!line || line.startsWith("<!--"))
434
+ continue;
435
+ const dated = line.match(/^(\d{4}-\d{2}-\d{2})\s*[::]\s*(.+)$/);
436
+ const date = dated ? dated[1] : fallbackDate;
437
+ const text = cleanRecentTopic(dated ? dated[2] : line);
438
+ if (!text)
439
+ continue;
440
+ const key = normalizeForDedupe(text);
441
+ if (!key || seen.has(key))
442
+ continue;
443
+ seen.add(key);
444
+ topics.push({ date, text });
445
+ }
446
+ return topics;
447
+ }
448
+ function pruneStaleTopics(topics, targetDate, days) {
449
+ const cutoff = addDays(dateFromLocalDateString(targetDate), -(days - 1));
450
+ const cutoffKey = localDateString(cutoff);
451
+ return topics.filter((topic) => topic.date >= cutoffKey);
452
+ }
453
+ function mergeDatedTopics(existing, incoming, targetDate) {
454
+ const byKey = new Map();
455
+ const order = [];
456
+ for (const topic of existing) {
457
+ const key = normalizeForDedupe(topic.text);
458
+ if (!key || byKey.has(key))
459
+ continue;
460
+ byKey.set(key, topic);
461
+ order.push(key);
462
+ }
463
+ for (const topic of incoming) {
464
+ const key = normalizeForDedupe(topic.text);
465
+ if (!key || byKey.has(key))
466
+ continue;
467
+ byKey.set(key, { date: topic.date || targetDate, text: topic.text });
468
+ order.push(key);
469
+ }
470
+ const merged = order.map((key) => byKey.get(key)).filter(Boolean);
471
+ merged.sort((a, b) => (a.date === b.date ? 0 : a.date < b.date ? 1 : -1));
472
+ return merged.slice(0, MAX_RECENT_TOPIC_ITEMS);
473
+ }
474
+ async function requestRecentTopicsForDate(config, input) {
475
+ const existingBlock = input.existingTopics.length > 0
476
+ ? input.existingTopics.map((topic) => `- ${topic.date}: ${topic.text}`).join("\n")
477
+ : "(none)";
478
+ const response = await getAiProvider()(config, {
479
+ model_id: input.modelId,
480
+ temperature: 0,
481
+ messages: [
482
+ {
483
+ role: "system",
484
+ content: [
485
+ "You extract recent conversation topics for one day, to append into Agent-Sin's short-lived 'Recent 7-day topics' section inside memory.md.",
486
+ "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.",
487
+ "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.",
488
+ "Keep concrete project, product, game, and skill names when useful. Merge duplicates within the day. Maximum 5 new topics.",
489
+ "Each topic: one concise sentence, in the same language as the source.",
490
+ "Do not include secrets, tokens, raw logs, private identifiers, URL lists, or sensitive facts about other people.",
491
+ "Avoid detailed private finance or family facts; if such a thread matters, keep it broad and non-identifying.",
492
+ "Do not restate stable preferences unless they were an active topic today.",
493
+ 'Output JSON only: {"topics":[{"text":"..."}]}. If nothing qualifies, return {"topics":[]}.',
494
+ ].join("\n"),
495
+ },
496
+ {
497
+ role: "user",
498
+ content: [
499
+ `Target date: ${input.targetDate}`,
500
+ "",
501
+ "<existing-recent-topics>",
502
+ existingBlock,
503
+ "</existing-recent-topics>",
504
+ "",
505
+ `<daily-conversation.md date="${input.targetDate}">`,
506
+ input.dailyContent,
507
+ `</daily-conversation.md>`,
508
+ ].join("\n").slice(0, MAX_RECENT_TOPIC_INPUT_CHARS),
509
+ },
510
+ ],
511
+ });
512
+ const texts = parseRecentTopicsResponse(response.text);
513
+ return texts.map((text) => ({ date: input.targetDate, text }));
514
+ }
515
+ function compactDailyMemoryForRecentTopics(raw) {
516
+ const parsed = parseDailyMemoryEntries(raw);
517
+ if (parsed.entries.length === 0) {
518
+ return clipAround(raw, MAX_RECENT_TOPIC_DAILY_CHARS);
519
+ }
520
+ const userEntries = parsed.entries
521
+ .filter((entry) => entry.role === "user")
522
+ .map((entry) => formatRecentTopicEntry(entry, MAX_RECENT_TOPIC_USER_ENTRY_CHARS))
523
+ .filter(Boolean);
524
+ const contextEntries = parsed.entries
525
+ .filter((entry) => entry.role === "assistant" || entry.role === "tool")
526
+ .map((entry) => formatRecentTopicEntry(entry, entry.role === "tool" ? MAX_RECENT_TOPIC_TOOL_ENTRY_CHARS : MAX_RECENT_TOPIC_ASSISTANT_ENTRY_CHARS))
527
+ .filter(Boolean);
528
+ const header = [
529
+ parsed.title,
530
+ "",
531
+ "<!-- Compacted for recent topic extraction. Direct user entries are prioritized; assistant/tool entries are included as context when budget allows. -->",
532
+ "",
533
+ ].join("\n");
534
+ const directSection = userEntries.length > 0
535
+ ? ["## Direct user entries", "", userEntries.join("\n\n")].join("\n")
536
+ : "## Direct user entries\n\n(no direct user entries)";
537
+ const contextSection = contextEntries.length > 0
538
+ ? ["## Assistant/tool context", "", contextEntries.join("\n\n")].join("\n")
539
+ : "";
540
+ const directOnly = `${header}${directSection}`.trimEnd();
541
+ if (directOnly.length >= MAX_RECENT_TOPIC_DAILY_CHARS || !contextSection) {
542
+ return `${clipAround(directOnly, MAX_RECENT_TOPIC_DAILY_CHARS)}\n`;
543
+ }
544
+ const remaining = MAX_RECENT_TOPIC_DAILY_CHARS - directOnly.length - 2;
545
+ return `${directOnly}\n\n${clipAround(contextSection, Math.max(0, remaining))}\n`;
546
+ }
547
+ function parseDailyMemoryEntries(raw) {
548
+ const lines = raw.split(/\r?\n/);
549
+ const title = lines.find((line) => /^#\s+\S/.test(line.trim())) || "# Daily conversation memory";
550
+ const entries = [];
551
+ let current = null;
552
+ for (const line of lines) {
553
+ const heading = line.match(/^##\s+(.+?)\s*$/);
554
+ if (heading) {
555
+ if (current) {
556
+ entries.push({
557
+ heading: current.heading,
558
+ role: dailyMemoryHeadingRole(current.heading),
559
+ body: current.body.join("\n").trim(),
560
+ });
561
+ }
562
+ current = { heading: heading[1].trim(), body: [] };
563
+ continue;
564
+ }
565
+ if (current) {
566
+ current.body.push(line);
567
+ }
568
+ }
569
+ if (current) {
570
+ entries.push({
571
+ heading: current.heading,
572
+ role: dailyMemoryHeadingRole(current.heading),
573
+ body: current.body.join("\n").trim(),
574
+ });
575
+ }
576
+ return { title, entries };
577
+ }
578
+ function dailyMemoryHeadingRole(heading) {
579
+ const match = heading.trim().match(/\b(system|user|assistant|tool)\s*$/);
580
+ return match?.[1] || "";
581
+ }
582
+ function formatRecentTopicEntry(entry, maxBodyChars) {
583
+ const body = normalizeRecentTopicEntryBody(entry.role, entry.body);
584
+ if (!body) {
585
+ return "";
586
+ }
587
+ return [`### ${entry.heading}`, clipAround(body, maxBodyChars)].join("\n\n");
588
+ }
589
+ function normalizeRecentTopicEntryBody(role, body) {
590
+ const trimmed = body.trim();
591
+ if (!trimmed) {
592
+ return "";
593
+ }
594
+ if (role === "assistant") {
595
+ const parsed = parseJsonObject(trimmed);
596
+ if (parsed) {
597
+ return normalizeAssistantEnvelope(parsed) || scrubRecentTopicText(trimmed);
598
+ }
599
+ }
600
+ if (role === "tool") {
601
+ const fenced = trimmed.match(/```skill-result\s*([\s\S]*?)```/i);
602
+ const parsed = parseJsonObject(fenced?.[1]?.trim() || trimmed);
603
+ if (parsed) {
604
+ const summary = parsed.summary;
605
+ return scrubRecentTopicText(typeof summary === "string" ? summary : trimmed);
606
+ }
607
+ }
608
+ return scrubRecentTopicText(trimmed);
609
+ }
610
+ function normalizeAssistantEnvelope(value) {
611
+ const parts = [];
612
+ if (typeof value.reply === "string" && value.reply.trim()) {
613
+ parts.push(value.reply.trim());
614
+ }
615
+ const calls = Array.isArray(value.skill_calls) ? value.skill_calls : [];
616
+ const callTexts = calls
617
+ .map((call) => {
618
+ if (!call || typeof call !== "object")
619
+ return "";
620
+ const record = call;
621
+ const id = typeof record.id === "string" ? record.id : "";
622
+ const args = record.args && typeof record.args === "object" ? record.args : {};
623
+ const text = typeof args.text === "string" ? args.text : "";
624
+ return [id, text].filter(Boolean).join(": ");
625
+ })
626
+ .filter(Boolean);
627
+ if (callTexts.length > 0) {
628
+ parts.push(`Skill calls: ${callTexts.join("; ")}`);
629
+ }
630
+ return scrubRecentTopicText(parts.join("\n\n"));
631
+ }
632
+ function parseJsonObject(text) {
633
+ try {
634
+ const parsed = JSON.parse(text);
635
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
636
+ ? parsed
637
+ : null;
638
+ }
639
+ catch {
640
+ return null;
641
+ }
642
+ }
643
+ function scrubRecentTopicText(text) {
644
+ return text
645
+ .replace(/\/Users\/[^\s)]+/g, "[local path]")
646
+ .replace(/\s+\n/g, "\n")
647
+ .replace(/\n{3,}/g, "\n\n")
648
+ .trim();
649
+ }
650
+ export function parseRecentTopicsResponse(text) {
651
+ const jsonText = extractJson(text);
652
+ let parsed;
653
+ try {
654
+ parsed = JSON.parse(jsonText);
655
+ }
656
+ catch {
657
+ return [];
658
+ }
659
+ const rawItems = Array.isArray(parsed)
660
+ ? parsed
661
+ : parsed && typeof parsed === "object" && Array.isArray(parsed.topics)
662
+ ? parsed.topics
663
+ : parsed && typeof parsed === "object" && Array.isArray(parsed.items)
664
+ ? parsed.items
665
+ : [];
666
+ const seen = new Set();
667
+ const topics = [];
668
+ for (const item of rawItems) {
669
+ const textValue = topicText(item);
670
+ const cleaned = cleanRecentTopic(textValue);
671
+ const key = normalizeForDedupe(cleaned);
672
+ if (!cleaned || !key || seen.has(key)) {
673
+ continue;
674
+ }
675
+ seen.add(key);
676
+ topics.push(cleaned);
677
+ if (topics.length >= MAX_RECENT_TOPIC_ITEMS) {
678
+ break;
679
+ }
680
+ }
681
+ return topics;
682
+ }
683
+ function topicText(item) {
684
+ if (typeof item === "string") {
685
+ return item;
686
+ }
687
+ if (item && typeof item === "object") {
688
+ const record = item;
689
+ const value = record.text || record.topic || record.summary || record.content;
690
+ return typeof value === "string" ? value : "";
691
+ }
692
+ return "";
693
+ }
694
+ function cleanRecentTopic(text) {
695
+ return text
696
+ .replace(/\s+/g, " ")
697
+ .replace(/^[-*・]\s*/, "")
698
+ .trim()
699
+ .slice(0, MAX_RECENT_TOPIC_CHARS);
700
+ }
701
+ function upsertRecentTopicsSection(original, endDate, topics) {
702
+ const base = removeRecentTopicsSection(original).replace(/\s+$/, "");
703
+ const section = formatRecentTopicsSection(endDate, topics);
704
+ return `${base}\n\n${section}\n`;
705
+ }
706
+ function formatRecentTopicsSection(endDate, topics) {
707
+ return [
708
+ l("## Recent 7-day topics", "## 直近1週間のトピック"),
709
+ "",
710
+ `<!-- ${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日より古い項目は自動で削除される。`)} -->`,
711
+ "",
712
+ ...topics.map((topic) => `- ${topic.date}: ${topic.text}`),
713
+ ].join("\n");
714
+ }
715
+ export function removeRecentTopicsSection(text) {
716
+ const lines = text.split(/\r?\n/);
717
+ const start = lines.findIndex((line) => recentTopicsHeading(line));
718
+ if (start < 0) {
719
+ return text;
720
+ }
721
+ let end = start + 1;
722
+ while (end < lines.length && !/^#{1,6}\s+\S/.test(lines[end].trim())) {
723
+ end += 1;
724
+ }
725
+ return [...lines.slice(0, start), ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
726
+ }
727
+ function recentTopicsHeading(line) {
728
+ return /^##\s+(?:Recent 7-day topics|直近1週間のトピック)\s*$/.test(line.trim());
729
+ }
201
730
  const MEMORY_CONSOLIDATE_INPUT_MAX = 32000;
202
731
  const MEMORY_HEADER_PATTERN = /^# memory\.md\s*$/im;
203
732
  async function consolidateMemoryFile(config, modelId, eventSource) {
204
733
  const file = profileMemoryPath(config, "memory");
205
734
  const original = await readTextIfExists(file);
206
- if (!original.trim())
735
+ const originalWithoutRecentTopics = removeRecentTopicsSection(original);
736
+ if (!originalWithoutRecentTopics.trim())
207
737
  return;
208
- const headerBlock = extractHeaderBlock(original);
209
- const body = original.slice(headerBlock.length);
738
+ const headerBlock = extractHeaderBlock(originalWithoutRecentTopics);
739
+ const body = originalWithoutRecentTopics.slice(headerBlock.length);
210
740
  if (!body.trim())
211
741
  return;
212
742
  try {
@@ -217,18 +747,20 @@ async function consolidateMemoryFile(config, modelId, eventSource) {
217
747
  {
218
748
  role: "system",
219
749
  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.",
750
+ "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.",
751
+ "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
752
  "Output rules:",
222
753
  "- Return only the Markdown body. No preface, afterword, or code fences.",
223
754
  "- 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.",
755
+ "- Normalize content under headings to one fact per bullet line starting with '-'. Do not create date headers or 'Auto promotion: ...' / '自動昇格: ...' headings.",
225
756
  "- 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.",
757
+ "- Prioritize keeping anything useful for understanding the user across future conversations: role, expertise, preferences, values, communication tendencies, decision habits, media preferences, recurring interests, stable dislikes, and long-running themes worth remembering even when they no longer come up daily.",
758
+ "- 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.",
759
+ "- 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.",
228
760
  "- Do not keep sensitive finance/family information or information that identifies other people.",
229
761
  "- Do not invent content. Do not add facts not present in the source.",
230
762
  "- 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.",
763
+ "- 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
764
  "Even if the target is empty or does not need much cleanup, return the minimal bullet list that preserves the current state.",
233
765
  ].join("\n"),
234
766
  },
@@ -242,9 +774,8 @@ async function consolidateMemoryFile(config, modelId, eventSource) {
242
774
  if (!cleaned)
243
775
  return;
244
776
  const next = `${headerBlock.replace(/\s+$/, "")}\n\n${cleaned}\n`;
245
- if (normalizeForCompare(next) === normalizeForCompare(original))
777
+ if (normalizeForCompare(next) === normalizeForCompare(originalWithoutRecentTopics))
246
778
  return;
247
- await writeFile(`${file}.bak`, original, "utf8");
248
779
  await writeFile(file, next, "utf8");
249
780
  await appendEventLog(config, {
250
781
  level: "info",
@@ -291,7 +822,7 @@ function sanitizeConsolidatedBody(text) {
291
822
  .split(/\r?\n/)
292
823
  .map((line) => line.trimEnd())
293
824
  .filter((line) => line.length > 0)
294
- .map((line) => (line.startsWith("- ") || line.startsWith("# ") ? line : line.replace(/^[-*・]?\s*/, "- ")));
825
+ .map((line) => (line.startsWith("- ") || /^#{1,6}\s+\S/.test(line) ? line : line.replace(/^[-*・]?\s*/, "- ")));
295
826
  return lines.join("\n").trim();
296
827
  }
297
828
  function isStructuredModelPayload(text) {
@@ -381,6 +912,15 @@ function localDateString(date) {
381
912
  const dd = String(date.getDate()).padStart(2, "0");
382
913
  return `${yyyy}-${MM}-${dd}`;
383
914
  }
915
+ function dateFromLocalDateString(value) {
916
+ const [yyyy, MM, dd] = value.split("-").map((part) => Number.parseInt(part, 10));
917
+ return new Date(yyyy, MM - 1, dd);
918
+ }
919
+ function addDays(date, days) {
920
+ const next = new Date(date);
921
+ next.setDate(next.getDate() + days);
922
+ return next;
923
+ }
384
924
  function dailyMemoryFileForDate(config, date) {
385
925
  const [yyyy, MM, dd] = date.split("-").map((part) => Number.parseInt(part, 10));
386
926
  return dailyConversationMemoryFile(config, new Date(yyyy, MM - 1, dd));
@@ -420,3 +960,17 @@ function clip(text, max) {
420
960
  }
421
961
  return `${text.slice(0, max)}\n\n... clipped ...`;
422
962
  }
963
+ function clipAround(text, max) {
964
+ if (max <= 0) {
965
+ return "";
966
+ }
967
+ if (text.length <= max) {
968
+ return text;
969
+ }
970
+ if (max < 40) {
971
+ return text.slice(0, max);
972
+ }
973
+ const marker = "\n\n... clipped middle ...\n\n";
974
+ const side = Math.floor((max - marker.length) / 2);
975
+ return `${text.slice(0, side)}${marker}${text.slice(text.length - side)}`;
976
+ }