codeksei 0.1.0 → 0.1.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 (68) hide show
  1. package/LICENSE +661 -661
  2. package/README.en.md +109 -47
  3. package/README.md +79 -58
  4. package/bin/cyberboss.js +1 -1
  5. package/package.json +86 -86
  6. package/scripts/open_shared_wechat_thread.sh +77 -77
  7. package/scripts/open_wechat_thread.sh +108 -108
  8. package/scripts/shared-common.js +144 -144
  9. package/scripts/shared-open.js +14 -14
  10. package/scripts/shared-start.js +5 -5
  11. package/scripts/shared-status.js +27 -27
  12. package/scripts/show_shared_status.sh +45 -45
  13. package/scripts/start_shared_app_server.sh +52 -52
  14. package/scripts/start_shared_wechat.sh +94 -94
  15. package/scripts/timeline-screenshot.sh +14 -14
  16. package/src/adapters/channel/weixin/account-store.js +99 -99
  17. package/src/adapters/channel/weixin/api-v2.js +50 -50
  18. package/src/adapters/channel/weixin/api.js +169 -169
  19. package/src/adapters/channel/weixin/context-token-store.js +84 -84
  20. package/src/adapters/channel/weixin/index.js +618 -604
  21. package/src/adapters/channel/weixin/legacy.js +579 -566
  22. package/src/adapters/channel/weixin/media-mime.js +22 -22
  23. package/src/adapters/channel/weixin/media-receive.js +370 -370
  24. package/src/adapters/channel/weixin/media-send.js +102 -102
  25. package/src/adapters/channel/weixin/message-utils-v2.js +282 -282
  26. package/src/adapters/channel/weixin/message-utils.js +199 -199
  27. package/src/adapters/channel/weixin/redact.js +41 -41
  28. package/src/adapters/channel/weixin/reminder-queue-store.js +101 -101
  29. package/src/adapters/channel/weixin/sync-buffer-store.js +35 -35
  30. package/src/adapters/runtime/codex/events.js +215 -215
  31. package/src/adapters/runtime/codex/index.js +109 -104
  32. package/src/adapters/runtime/codex/message-utils.js +95 -95
  33. package/src/adapters/runtime/codex/model-catalog.js +106 -106
  34. package/src/adapters/runtime/codex/protocol-leak-monitor.js +75 -75
  35. package/src/adapters/runtime/codex/rpc-client.js +339 -339
  36. package/src/adapters/runtime/codex/session-store.js +286 -286
  37. package/src/app/channel-send-file-cli.js +57 -57
  38. package/src/app/diary-write-cli.js +236 -88
  39. package/src/app/note-sync-cli.js +2 -2
  40. package/src/app/reminder-write-cli.js +215 -210
  41. package/src/app/review-cli.js +7 -5
  42. package/src/app/system-checkin-poller.js +64 -64
  43. package/src/app/system-send-cli.js +129 -129
  44. package/src/app/timeline-event-cli.js +28 -25
  45. package/src/app/timeline-screenshot-cli.js +103 -100
  46. package/src/core/app.js +1763 -1763
  47. package/src/core/branding.js +2 -1
  48. package/src/core/command-registry.js +381 -369
  49. package/src/core/config.js +30 -14
  50. package/src/core/default-targets.js +163 -163
  51. package/src/core/durable-note-schema.js +9 -8
  52. package/src/core/instructions-template.js +17 -16
  53. package/src/core/note-sync.js +8 -7
  54. package/src/core/path-utils.js +54 -0
  55. package/src/core/project-radar.js +11 -10
  56. package/src/core/review.js +48 -50
  57. package/src/core/stream-delivery.js +1162 -983
  58. package/src/core/system-message-dispatcher.js +68 -68
  59. package/src/core/system-message-queue-store.js +128 -128
  60. package/src/core/thread-state-store.js +96 -96
  61. package/src/core/timeline-screenshot-queue-store.js +134 -134
  62. package/src/core/timezone.js +436 -0
  63. package/src/core/workspace-bootstrap.js +9 -1
  64. package/src/index.js +148 -146
  65. package/src/integrations/timeline/index.js +130 -74
  66. package/src/integrations/timeline/state-sync.js +240 -0
  67. package/templates/weixin-instructions.md +12 -38
  68. package/templates/weixin-operations.md +29 -31
@@ -1,57 +1,57 @@
1
- async function runChannelSendFileCommand(app) {
2
- const args = process.argv.slice(4);
3
- if (args.includes("--help") || args.includes("-h")) {
4
- printHelp();
5
- return;
6
- }
7
-
8
- const options = parseArgs(args);
9
- if (!options.path) {
10
- throw new Error("缺少 --path,指定要发回微信的本地文件路径");
11
- }
12
-
13
- const result = await app.sendLocalFileToCurrentChat({
14
- senderId: options.user,
15
- filePath: options.path,
16
- });
17
- console.log(`file sent: ${result.filePath}`);
18
- }
19
-
20
- function parseArgs(args) {
21
- const options = {
22
- path: "",
23
- user: "",
24
- };
25
-
26
- for (let index = 0; index < args.length; index += 1) {
27
- const arg = String(args[index] || "");
28
- if (arg === "--path") {
29
- options.path = String(args[index + 1] || "");
30
- index += 1;
31
- continue;
32
- }
33
- if (arg === "--user") {
34
- options.user = String(args[index + 1] || "");
35
- index += 1;
36
- continue;
37
- }
38
- throw new Error(`未知参数: ${arg}`);
39
- }
40
-
41
- return options;
42
- }
43
-
44
- function printHelp() {
45
- console.log([
46
- "用法: npm run channel:send-file -- --path /绝对路径 [--user <wechatUserId>]",
47
- "",
48
- "参数:",
49
- " --path /绝对路径 要发回当前微信聊天的本地文件",
50
- " --user <wechatUserId> 可选,覆盖默认接收用户",
51
- "",
52
- "示例:",
53
- " npm run channel:send-file -- --path /Users/name/project/README.md",
54
- ].join("\n"));
55
- }
56
-
57
- module.exports = { runChannelSendFileCommand };
1
+ async function runChannelSendFileCommand(app) {
2
+ const args = process.argv.slice(4);
3
+ if (args.includes("--help") || args.includes("-h")) {
4
+ printHelp();
5
+ return;
6
+ }
7
+
8
+ const options = parseArgs(args);
9
+ if (!options.path) {
10
+ throw new Error("缺少 --path,指定要发回微信的本地文件路径");
11
+ }
12
+
13
+ const result = await app.sendLocalFileToCurrentChat({
14
+ senderId: options.user,
15
+ filePath: options.path,
16
+ });
17
+ console.log(`file sent: ${result.filePath}`);
18
+ }
19
+
20
+ function parseArgs(args) {
21
+ const options = {
22
+ path: "",
23
+ user: "",
24
+ };
25
+
26
+ for (let index = 0; index < args.length; index += 1) {
27
+ const arg = String(args[index] || "");
28
+ if (arg === "--path") {
29
+ options.path = String(args[index + 1] || "");
30
+ index += 1;
31
+ continue;
32
+ }
33
+ if (arg === "--user") {
34
+ options.user = String(args[index + 1] || "");
35
+ index += 1;
36
+ continue;
37
+ }
38
+ throw new Error(`未知参数: ${arg}`);
39
+ }
40
+
41
+ return options;
42
+ }
43
+
44
+ function printHelp() {
45
+ console.log([
46
+ "用法: npm run channel:send-file -- --path /绝对路径 [--user <wechatUserId>]",
47
+ "",
48
+ "参数:",
49
+ " --path /绝对路径 要发回当前微信聊天的本地文件",
50
+ " --user <wechatUserId> 可选,覆盖默认接收用户",
51
+ "",
52
+ "示例:",
53
+ " npm run channel:send-file -- --path /Users/name/project/README.md",
54
+ ].join("\n"));
55
+ }
56
+
57
+ module.exports = { runChannelSendFileCommand };
@@ -1,6 +1,12 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
3
  const { PACKAGE_NAME } = require("../core/branding");
4
+ const {
5
+ LEGACY_TIMELINE_TIMEZONE,
6
+ formatDateInTimezone,
7
+ formatDateTimeInTimezone,
8
+ formatTimeInTimezone,
9
+ } = require("../core/timezone");
4
10
 
5
11
  const DEFAULT_SECTION = "supplement";
6
12
  const SECTION_HEADINGS = Object.freeze({
@@ -14,6 +20,8 @@ const TODO_STATE_MARKERS = Object.freeze({
14
20
  open: " ",
15
21
  done: "x",
16
22
  });
23
+ const TODO_LINE_RE = /^- \[( |x|X)\] (.*)$/u;
24
+ const TODO_START_MARKER_RE = /\s*<!--\s*codeksei-todo:start=(\d{2}:\d{2})\s*-->\s*$/u;
17
25
 
18
26
  async function runDiaryWriteCommand(config) {
19
27
  const args = process.argv.slice(4);
@@ -22,36 +30,47 @@ async function runDiaryWriteCommand(config) {
22
30
  if (!body) {
23
31
  throw new Error("日记内容不能为空,传 --text 或通过 stdin 输入");
24
32
  }
25
-
33
+
26
34
  const now = new Date();
27
- const dateString = options.date || formatDate(now);
28
- const timeString = options.time || formatTime(now);
35
+ const timezone = config?.timezone || LEGACY_TIMELINE_TIMEZONE;
36
+ const dateString = options.date || formatDate(now, timezone);
37
+ const timeString = options.time || formatTime(now, timezone);
29
38
  const section = normalizeSection(options.section);
30
- const todoState = normalizeTodoState(options.state, section);
31
- const usesLegacyTodoDoneFallback = shouldSynthesizeTodoDoneTimelineText({
32
- section,
33
- todoState,
34
- timelineText: options.timelineText,
35
- });
36
39
  const filePath = path.join(config.diaryDir, `${dateString}.md`);
37
- const entryPayloads = buildDiaryWriteEntryPayloads({
40
+ fs.mkdirSync(config.diaryDir, { recursive: true });
41
+ ensureDiaryFile(filePath, now, timezone);
42
+ const current = fs.readFileSync(filePath, "utf8");
43
+ const timelineResolution = resolveTodoDoneTimelineText({
44
+ existingContent: current,
38
45
  section,
39
46
  timeString,
40
47
  title: options.title,
41
48
  body,
42
- todoState,
49
+ // Keep the raw CLI flag here too. Otherwise non-todo writes inherit the
50
+ // synthesized internal "open" default and trip the same guard that is
51
+ // meant only for explicit --state misuse.
52
+ todoState: options.state,
43
53
  timelineText: options.timelineText,
44
54
  });
45
- if (usesLegacyTodoDoneFallback) {
55
+ if (timelineResolution.mode === "point_in_time") {
46
56
  console.warn(
47
- `[${PACKAGE_NAME}] diary:write legacy todo-done call omitted --timeline-text; `
48
- + "synthesized a minimal point-in-time diary fact to keep the cutover atomic."
57
+ `[${PACKAGE_NAME}] diary:write todo-done call omitted --timeline-text and no captured Todo start time was found; `
58
+ + "synthesized only a point-in-time diary fact. Prefer opening the live Todo earlier, or pass exact cutover wording via --timeline-text."
49
59
  );
50
60
  }
51
-
52
- fs.mkdirSync(config.diaryDir, { recursive: true });
53
- ensureDiaryFile(filePath, now);
54
- const current = fs.readFileSync(filePath, "utf8");
61
+ const entryPayloads = buildDiaryWriteEntryPayloads({
62
+ existingContent: current,
63
+ section,
64
+ timeString,
65
+ title: options.title,
66
+ body,
67
+ // Keep the raw CLI flag here. Non-todo writes internally normalize the
68
+ // missing state to "open" for cutover bookkeeping, but treating that
69
+ // synthesized default as an explicit --state would wrongly reject
70
+ // fragment/timeline writes.
71
+ todoState: options.state,
72
+ timelineText: options.timelineText,
73
+ });
55
74
  const next = entryPayloads.reduce(
56
75
  (draft, payload) => insertDiaryEntry(draft, payload, dateString),
57
76
  current
@@ -61,7 +80,7 @@ async function runDiaryWriteCommand(config) {
61
80
  }
62
81
 
63
82
  function parseArgs(args) {
64
- const options = {
83
+ const options = {
65
84
  text: "",
66
85
  title: "",
67
86
  date: "",
@@ -124,30 +143,30 @@ function readOptionValue(args, index, optionName) {
124
143
  }
125
144
  return String(next);
126
145
  }
127
-
128
- async function resolveBody(options) {
129
- const inlineText = normalizeBody(options.text);
130
- if (inlineText) {
131
- return inlineText;
132
- }
133
- if (!options.useStdin && process.stdin.isTTY) {
134
- return "";
135
- }
136
- return normalizeBody(await readStdin());
137
- }
138
-
139
- function readStdin() {
140
- return new Promise((resolve, reject) => {
141
- let buffer = "";
142
- process.stdin.setEncoding("utf8");
143
- process.stdin.on("data", (chunk) => {
144
- buffer += chunk;
145
- });
146
- process.stdin.on("end", () => resolve(buffer));
147
- process.stdin.on("error", reject);
148
- });
149
- }
150
-
146
+
147
+ async function resolveBody(options) {
148
+ const inlineText = normalizeBody(options.text);
149
+ if (inlineText) {
150
+ return inlineText;
151
+ }
152
+ if (!options.useStdin && process.stdin.isTTY) {
153
+ return "";
154
+ }
155
+ return normalizeBody(await readStdin());
156
+ }
157
+
158
+ function readStdin() {
159
+ return new Promise((resolve, reject) => {
160
+ let buffer = "";
161
+ process.stdin.setEncoding("utf8");
162
+ process.stdin.on("data", (chunk) => {
163
+ buffer += chunk;
164
+ });
165
+ process.stdin.on("end", () => resolve(buffer));
166
+ process.stdin.on("error", reject);
167
+ });
168
+ }
169
+
151
170
  function buildDiaryEntry({ timeString, title, body }) {
152
171
  const heading = title ? `### ${timeString} ${title.trim()}` : `### ${timeString}`;
153
172
  return `${heading}\n\n${body}`;
@@ -181,11 +200,17 @@ function buildDiaryEntryPayload({ section = DEFAULT_SECTION, timeString, title,
181
200
 
182
201
  if (normalizedSection === "todo") {
183
202
  const normalizedState = normalizeTodoState(todoState, normalizedSection);
203
+ const todoStartedAt = normalizedState === "open" ? normalizeTodoClock(timeString) : "";
184
204
  return {
185
205
  section: normalizedSection,
186
- entry: `- [${TODO_STATE_MARKERS[normalizedState]}] ${lineText}`,
206
+ entry: buildTodoLine({
207
+ text: lineText,
208
+ todoState: normalizedState,
209
+ todoStartedAt,
210
+ }),
187
211
  text: lineText,
188
212
  todoState: normalizedState,
213
+ todoStartedAt,
189
214
  };
190
215
  }
191
216
 
@@ -197,17 +222,25 @@ function buildDiaryEntryPayload({ section = DEFAULT_SECTION, timeString, title,
197
222
  }
198
223
 
199
224
  function buildDiaryWriteEntryPayloads({
225
+ existingContent = "",
200
226
  section = DEFAULT_SECTION,
201
227
  timeString,
202
228
  title,
203
229
  body,
204
- todoState = "open",
230
+ todoState = "",
205
231
  timelineText = "",
206
232
  }) {
207
233
  const normalizedSection = normalizeSection(section);
208
234
  const normalizedTodoState = normalizeTodoState(todoState, normalizedSection);
209
- const normalizedTimelineText = normalizeLineItem(timelineText)
210
- || synthesizeTodoDoneTimelineText({ section, timeString, title, body, todoState });
235
+ const normalizedTimelineText = resolveTodoDoneTimelineText({
236
+ existingContent,
237
+ section,
238
+ timeString,
239
+ title,
240
+ body,
241
+ todoState,
242
+ timelineText,
243
+ }).text;
211
244
 
212
245
  if (normalizedTimelineText && (normalizedSection !== "todo" || normalizedTodoState !== "done")) {
213
246
  throw new Error("--timeline-text 只支持和 --section todo --state done 一起使用");
@@ -242,12 +275,55 @@ function shouldSynthesizeTodoDoneTimelineText({ section = DEFAULT_SECTION, todoS
242
275
  && !normalizeLineItem(timelineText);
243
276
  }
244
277
 
278
+ function resolveTodoDoneTimelineText({
279
+ existingContent = "",
280
+ section = DEFAULT_SECTION,
281
+ timeString = "",
282
+ title = "",
283
+ body = "",
284
+ todoState = "open",
285
+ timelineText = "",
286
+ }) {
287
+ const explicitTimelineText = normalizeLineItem(timelineText);
288
+ if (explicitTimelineText) {
289
+ return {
290
+ text: explicitTimelineText,
291
+ mode: "explicit",
292
+ };
293
+ }
294
+
295
+ if (!shouldSynthesizeTodoDoneTimelineText({ section, todoState })) {
296
+ return {
297
+ text: "",
298
+ mode: "none",
299
+ };
300
+ }
301
+
302
+ const existingTodoStartTime = findTodoStartTimeInDiaryContent(existingContent, {
303
+ title,
304
+ body,
305
+ });
306
+ const synthesized = synthesizeTodoDoneTimelineText({
307
+ section,
308
+ timeString,
309
+ title,
310
+ body,
311
+ todoState,
312
+ existingTodoStartTime,
313
+ });
314
+ return {
315
+ text: synthesized,
316
+ mode: existingTodoStartTime ? "range_from_todo" : "point_in_time",
317
+ };
318
+ }
319
+
245
320
  function synthesizeTodoDoneTimelineText({
246
321
  section = DEFAULT_SECTION,
247
322
  timeString = "",
248
323
  title = "",
249
324
  body = "",
250
325
  todoState = "open",
326
+ existingTodoStartTime = "",
251
327
  }) {
252
328
  if (!shouldSynthesizeTodoDoneTimelineText({ section, todoState })) {
253
329
  return "";
@@ -258,19 +334,23 @@ function synthesizeTodoDoneTimelineText({
258
334
  return "";
259
335
  }
260
336
 
261
- // Keep stale prompts from dropping the diary hard fact entirely. This
262
- // compatibility bridge records only a minimal point-in-time fact; fresh
263
- // callers should still pass --timeline-text with the real cutover context.
337
+ // Keep stale prompts from dropping the diary hard fact entirely. When the
338
+ // same live Todo already captured a reliable start time, reuse it here so
339
+ // todo-first workflow can still produce an accurate diary range at cutover.
340
+ const capturedStartTime = normalizeTodoClock(existingTodoStartTime);
264
341
  const normalizedTime = normalizeLineItem(timeString);
342
+ if (capturedStartTime && normalizedTime && capturedStartTime !== normalizedTime) {
343
+ return `${capturedStartTime}-${normalizedTime} ${lineText}`;
344
+ }
265
345
  return normalizedTime ? `${normalizedTime} ${lineText}` : lineText;
266
346
  }
267
347
 
268
- function ensureDiaryFile(filePath, now) {
348
+ function ensureDiaryFile(filePath, now, timezone = LEGACY_TIMELINE_TIMEZONE) {
269
349
  if (fs.existsSync(filePath) && fs.statSync(filePath).size > 0) {
270
350
  return;
271
351
  }
272
- const createdAt = formatDateTime(now);
273
- const updated = formatDate(now);
352
+ const createdAt = formatDateTime(now, timezone);
353
+ const updated = formatDate(now, timezone);
274
354
  fs.writeFileSync(filePath, buildDiaryFileSkeleton({ createdAt, updated }), "utf8");
275
355
  }
276
356
 
@@ -349,6 +429,7 @@ function normalizeEntryPayload(entry) {
349
429
  title: normalizeLineItem(payload.title),
350
430
  body: normalizeBody(payload.body),
351
431
  todoState: normalizeTodoState(payload.todoState, normalizeSection(payload.section)),
432
+ todoStartedAt: normalizeTodoClock(payload.todoStartedAt),
352
433
  };
353
434
  }
354
435
 
@@ -423,22 +504,49 @@ function isPlaceholderLine(line, section) {
423
504
 
424
505
  function upsertTodoLine(lines, payload) {
425
506
  const nextLines = Array.isArray(lines) ? [...lines] : [];
426
- const desiredLine = `- [${TODO_STATE_MARKERS[payload.todoState]}] ${payload.text}`;
427
507
  for (let index = 0; index < nextLines.length; index += 1) {
428
- const match = /^- \[( |x|X)\] (.*)$/u.exec(String(nextLines[index] || ""));
429
- if (!match) {
508
+ const parsed = parseTodoLine(nextLines[index]);
509
+ if (!parsed) {
430
510
  continue;
431
511
  }
432
- if (normalizeLineItem(match[2]) !== payload.text) {
512
+ if (parsed.text !== payload.text) {
433
513
  continue;
434
514
  }
435
- nextLines[index] = desiredLine;
515
+ nextLines[index] = buildTodoLine({
516
+ text: payload.text,
517
+ todoState: payload.todoState,
518
+ todoStartedAt: resolveTodoStartedAtForUpsert(parsed, payload),
519
+ });
436
520
  return nextLines;
437
521
  }
438
- nextLines.push(desiredLine);
522
+ nextLines.push(buildTodoLine({
523
+ text: payload.text,
524
+ todoState: payload.todoState,
525
+ todoStartedAt: payload.todoStartedAt,
526
+ }));
439
527
  return nextLines;
440
528
  }
441
529
 
530
+ function resolveTodoStartedAtForUpsert(parsed, payload) {
531
+ const existingState = parsed?.todoState || "open";
532
+ const existingStartedAt = normalizeTodoClock(parsed?.todoStartedAt);
533
+ const payloadStartedAt = normalizeTodoClock(payload?.todoStartedAt);
534
+ const nextState = normalizeTodoState(payload?.todoState, "todo");
535
+
536
+ // Repeated writes against the same live open Todo should keep the original
537
+ // block start. But if the same text gets reopened after it was already done,
538
+ // the new open write represents a fresh block and must reset the captured
539
+ // start time instead of leaking the old finished block's timestamp.
540
+ if (nextState === "open") {
541
+ if (existingState === "open") {
542
+ return existingStartedAt || payloadStartedAt;
543
+ }
544
+ return payloadStartedAt || existingStartedAt;
545
+ }
546
+
547
+ return existingStartedAt || payloadStartedAt;
548
+ }
549
+
442
550
  function upsertBulletLine(lines, payload) {
443
551
  const nextLines = Array.isArray(lines) ? [...lines] : [];
444
552
  for (const line of nextLines) {
@@ -531,6 +639,58 @@ function buildSectionLineText({ title, body }) {
531
639
  return normalizedTitle || normalizedBody;
532
640
  }
533
641
 
642
+ function buildTodoLine({ text, todoState = "open", todoStartedAt = "" }) {
643
+ const startMarker = buildTodoStartMarker(todoStartedAt);
644
+ return `- [${TODO_STATE_MARKERS[todoState]}] ${text}${startMarker}`;
645
+ }
646
+
647
+ function buildTodoStartMarker(value) {
648
+ const normalized = normalizeTodoClock(value);
649
+ return normalized ? ` <!-- codeksei-todo:start=${normalized} -->` : "";
650
+ }
651
+
652
+ function parseTodoLine(line) {
653
+ const match = TODO_LINE_RE.exec(String(line || ""));
654
+ if (!match) {
655
+ return null;
656
+ }
657
+ const rawText = String(match[2] || "");
658
+ const startMarker = TODO_START_MARKER_RE.exec(rawText);
659
+ return {
660
+ todoState: String(match[1] || "").toLowerCase() === "x" ? "done" : "open",
661
+ text: normalizeLineItem(stripTodoMetadata(rawText)),
662
+ todoStartedAt: normalizeTodoClock(startMarker?.[1] || ""),
663
+ };
664
+ }
665
+
666
+ function stripTodoMetadata(value) {
667
+ return String(value || "").replace(TODO_START_MARKER_RE, "").trim();
668
+ }
669
+
670
+ function findTodoStartTimeInDiaryContent(content, { title = "", body = "" } = {}) {
671
+ const normalizedContent = normalizeFileEnding(content);
672
+ if (!normalizedContent.trim()) {
673
+ return "";
674
+ }
675
+ const range = findSectionRange(normalizedContent, SECTION_HEADINGS.todo);
676
+ if (!range) {
677
+ return "";
678
+ }
679
+ const targetText = buildSectionLineText({ title, body });
680
+ if (!targetText) {
681
+ return "";
682
+ }
683
+ const lines = extractSectionLines(normalizedContent.slice(range.contentStart, range.end), "todo");
684
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
685
+ const parsed = parseTodoLine(lines[index]);
686
+ if (!parsed || parsed.text !== targetText) {
687
+ continue;
688
+ }
689
+ return parsed.todoStartedAt;
690
+ }
691
+ return "";
692
+ }
693
+
534
694
  function normalizeSection(value) {
535
695
  const normalized = String(value || "").trim().toLowerCase();
536
696
  switch (normalized) {
@@ -576,35 +736,21 @@ function normalizeLineItem(value) {
576
736
  return normalizeBody(value).replace(/\s*\n+\s*/g, " ").replace(/\s{2,}/g, " ").trim();
577
737
  }
578
738
 
579
- function formatDate(date) {
580
- return new Intl.DateTimeFormat("en-CA", {
581
- timeZone: "Asia/Shanghai",
582
- year: "numeric",
583
- month: "2-digit",
584
- day: "2-digit",
585
- }).format(date);
586
- }
587
-
588
- function formatTime(date) {
589
- return new Intl.DateTimeFormat("zh-CN", {
590
- timeZone: "Asia/Shanghai",
591
- hour: "2-digit",
592
- minute: "2-digit",
593
- hour12: false,
594
- }).format(date);
595
- }
596
-
597
- function formatDateTime(date) {
598
- const formatter = new Intl.DateTimeFormat("sv-SE", {
599
- timeZone: "Asia/Shanghai",
600
- year: "numeric",
601
- month: "2-digit",
602
- day: "2-digit",
603
- hour: "2-digit",
604
- minute: "2-digit",
605
- hour12: false,
606
- });
607
- return formatter.format(date).replace(" ", "T");
739
+ function normalizeTodoClock(value) {
740
+ const normalized = normalizeLineItem(value);
741
+ return /^\d{2}:\d{2}$/u.test(normalized) ? normalized : "";
742
+ }
743
+
744
+ function formatDate(date, timezone = LEGACY_TIMELINE_TIMEZONE) {
745
+ return formatDateInTimezone(date, timezone);
746
+ }
747
+
748
+ function formatTime(date, timezone = LEGACY_TIMELINE_TIMEZONE) {
749
+ return formatTimeInTimezone(date, timezone);
750
+ }
751
+
752
+ function formatDateTime(date, timezone = LEGACY_TIMELINE_TIMEZONE) {
753
+ return formatDateTimeInTimezone(date, timezone);
608
754
  }
609
755
 
610
756
  module.exports = {
@@ -616,5 +762,7 @@ module.exports = {
616
762
  normalizeSection,
617
763
  normalizeTodoState,
618
764
  parseArgs,
765
+ parseTodoLine,
619
766
  runDiaryWriteCommand,
767
+ resolveTodoDoneTimelineText,
620
768
  };
@@ -119,8 +119,8 @@ function printNoteSyncHelp() {
119
119
  "",
120
120
  "示例:",
121
121
  " npm run note:sync -- --project <slug> --section \"最近动作\" --text \"把微信 prompt 收口为更温柔的 chief-of-staff 风格\" --max-items 6",
122
- " npm run note:sync -- --project <slug> --section \"当前状态\" --slot current-status --style paragraph --text \"当前默认主 workspace 是 Website,shared bridge 正常运行。\"",
123
- " npm run note:sync -- --path \"项目/Codeksei 生活助理/README.md\" --section \"当前定位\" --text \"默认先接住,再定向,再推进,不做催债式主动提醒。\"",
122
+ " npm run note:sync -- --project <slug> --section \"当前状态\" --slot current-status --style paragraph --text \"当前 shared bridge 正常运行,默认入口稳定。\"",
123
+ " npm run note:sync -- --path \"/absolute/path/to/note.md\" --section \"当前定位\" --text \"默认先接住,再定向,再推进。\"",
124
124
  ].join("\n"));
125
125
  }
126
126