@kairyou/agent-tools 0.13.2 → 0.13.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/log/hook.mjs +112 -33
- package/docs/en/extras.md +6 -5
- package/docs/zh-CN/extras.md +5 -5
- package/integrations/log/hook.mjs +153 -45
- package/package.json +1 -1
- package/skills/workflow/at-daily-log/SKILL.md +3 -2
package/dist/log/hook.mjs
CHANGED
|
@@ -897,21 +897,75 @@ async function main() {
|
|
|
897
897
|
const statePath = path.join(CACHE_ROOT, `${day}.state.json`);
|
|
898
898
|
const snapshotsRoot = path.join(CACHE_ROOT, `${day}.snapshots`);
|
|
899
899
|
await fs.mkdir(CACHE_ROOT, { recursive: true });
|
|
900
|
-
await
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
900
|
+
const release = await acquireLock(path.join(CACHE_ROOT, `${day}.lock`));
|
|
901
|
+
if (!release) {
|
|
902
|
+
await debugLog(`dropped ${eventName}: could not acquire ${day}.lock`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
await cleanupCache(day);
|
|
907
|
+
const state = await loadState(statePath, day);
|
|
908
|
+
await handleEvent(state, hookInput, {
|
|
909
|
+
now,
|
|
907
910
|
snapshotsRoot,
|
|
908
|
-
|
|
909
|
-
scopeKey
|
|
911
|
+
effective,
|
|
912
|
+
scopeKey,
|
|
913
|
+
scopePath: scope?.path || ""
|
|
910
914
|
});
|
|
911
|
-
await
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
+
await writeFileAtomic(statePath, `${JSON.stringify(state, null, 2)}
|
|
916
|
+
`);
|
|
917
|
+
if (effective.format === "detailed") {
|
|
918
|
+
const report = await renderDetailedReport(state, {
|
|
919
|
+
snapshotsRoot,
|
|
920
|
+
language: effective.language,
|
|
921
|
+
scopeKey
|
|
922
|
+
});
|
|
923
|
+
await fs.mkdir(effective.output, { recursive: true });
|
|
924
|
+
await writeFileAtomic(path.join(effective.output, `${day}.md`), report);
|
|
925
|
+
} else {
|
|
926
|
+
await updateDailyFile(effective.output, day, buildDailyItems(state, scopeKey));
|
|
927
|
+
}
|
|
928
|
+
} finally {
|
|
929
|
+
await release();
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
var LOCK_TIMEOUT_MS = 5e3;
|
|
933
|
+
var LOCK_RETRY_MS = 25;
|
|
934
|
+
var LOCK_STALE_MS = 3e4;
|
|
935
|
+
async function acquireLock(lockPath) {
|
|
936
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
937
|
+
while (Date.now() < deadline) {
|
|
938
|
+
try {
|
|
939
|
+
const handle = await fs.open(lockPath, "wx");
|
|
940
|
+
await handle.close();
|
|
941
|
+
return async () => {
|
|
942
|
+
try {
|
|
943
|
+
await fs.unlink(lockPath);
|
|
944
|
+
} catch {
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
} catch (error) {
|
|
948
|
+
if (error?.code !== "EEXIST") return null;
|
|
949
|
+
const age = await fs.stat(lockPath).then(({ mtimeMs }) => Date.now() - mtimeMs).catch(() => 0);
|
|
950
|
+
if (age > LOCK_STALE_MS) {
|
|
951
|
+
await fs.unlink(lockPath).catch(() => {
|
|
952
|
+
});
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
await sleep(LOCK_RETRY_MS);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
function sleep(ms) {
|
|
961
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
962
|
+
}
|
|
963
|
+
async function debugLog(message) {
|
|
964
|
+
if (process.env.AGENT_TOOLS_LOG_DEBUG !== "1") return;
|
|
965
|
+
try {
|
|
966
|
+
await fs.appendFile(path.join(CACHE_ROOT, "debug.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${message}
|
|
967
|
+
`);
|
|
968
|
+
} catch {
|
|
915
969
|
}
|
|
916
970
|
}
|
|
917
971
|
function eventCwd(input) {
|
|
@@ -980,6 +1034,11 @@ async function handleEvent(state, input, context) {
|
|
|
980
1034
|
const session = ensureSession(state, sessionId, timestamp);
|
|
981
1035
|
session.last_time = timestamp;
|
|
982
1036
|
if (eventName === "UserPromptSubmit") {
|
|
1037
|
+
const last = session.turns[session.turns.length - 1];
|
|
1038
|
+
if (last && !last.request_text && last.result_summary) {
|
|
1039
|
+
last.request_text = excerptMultiline(getPromptText(input), 1600);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
983
1042
|
session.turns.push(
|
|
984
1043
|
createTurn(session, timestamp, getPromptText(input), resolveProjectLabel(input, context.scopePath), context.scopeKey)
|
|
985
1044
|
);
|
|
@@ -1028,7 +1087,7 @@ async function cleanupCache(day) {
|
|
|
1028
1087
|
}
|
|
1029
1088
|
await Promise.all(
|
|
1030
1089
|
entries.map(async (entry) => {
|
|
1031
|
-
const isState = entry.isFile() && /^\d{4}-\d{2}-\d{2}\.state\.json$/.test(entry.name);
|
|
1090
|
+
const isState = entry.isFile() && /^\d{4}-\d{2}-\d{2}\.(state\.json|lock)$/.test(entry.name);
|
|
1032
1091
|
const isSnapshots = entry.isDirectory() && /^\d{4}-\d{2}-\d{2}\.snapshots$/.test(entry.name);
|
|
1033
1092
|
if (!isState && !isSnapshots || entry.name.startsWith(day)) return;
|
|
1034
1093
|
try {
|
|
@@ -1192,30 +1251,46 @@ async function updateDailyFile(outputFile, day, items) {
|
|
|
1192
1251
|
(item, index) => ` ${index + 1}. ${item.project ? `${item.project}: ` : ""}${item.text}`
|
|
1193
1252
|
);
|
|
1194
1253
|
const block = [`<!-- log:${day}:start -->`, ...lines, `<!-- log:${day}:end -->`];
|
|
1195
|
-
let
|
|
1254
|
+
let raw = "";
|
|
1196
1255
|
try {
|
|
1197
|
-
|
|
1256
|
+
raw = await fs.readFile(outputFile, "utf8");
|
|
1198
1257
|
} catch {
|
|
1199
1258
|
}
|
|
1259
|
+
const hadBom = raw.charCodeAt(0) === 65279;
|
|
1260
|
+
const current = hadBom ? raw.slice(1) : raw;
|
|
1200
1261
|
const eol = current.includes("\r\n") ? "\r\n" : "\n";
|
|
1201
1262
|
const fileLines = current ? current.split(/\r?\n/) : [];
|
|
1202
1263
|
const startMarker = `<!-- log:${day}:start -->`;
|
|
1203
1264
|
const endMarker = `<!-- log:${day}:end -->`;
|
|
1204
|
-
const startIndex = fileLines.
|
|
1205
|
-
const endIndex = fileLines.findIndex(
|
|
1265
|
+
const startIndex = fileLines.findLastIndex((line) => line.trim() === startMarker);
|
|
1266
|
+
const endIndex = fileLines.findIndex(
|
|
1267
|
+
(line, index) => index > startIndex && line.trim() === endMarker
|
|
1268
|
+
);
|
|
1206
1269
|
let nextLines;
|
|
1207
1270
|
if (startIndex !== -1 && endIndex > startIndex) {
|
|
1208
1271
|
nextLines = [...fileLines.slice(0, startIndex), ...block, ...fileLines.slice(endIndex + 1)];
|
|
1209
|
-
} else if (startIndex !== -1 || endIndex !== -1) {
|
|
1210
|
-
console.error(`[agent-tools log] Unpaired markers for ${day} in ${outputFile}; skipped.`);
|
|
1211
|
-
return;
|
|
1212
1272
|
} else {
|
|
1213
1273
|
nextLines = insertDatedBlock(fileLines, day, block);
|
|
1214
1274
|
}
|
|
1215
|
-
const
|
|
1275
|
+
const body = nextLines.join(eol).replace(/(\r?\n)*$/, eol);
|
|
1276
|
+
const text = hadBom ? "\uFEFF" + body : body;
|
|
1216
1277
|
await fs.mkdir(path.dirname(outputFile), { recursive: true });
|
|
1217
1278
|
await writeFileAtomic(outputFile, text);
|
|
1218
1279
|
}
|
|
1280
|
+
var ENTRY_MARKER_RE = /^<!--\s*(?:daily-)?log:\d{4}-\d{2}-\d{2}:(?:start|end)\b/;
|
|
1281
|
+
function datedSectionEnd(fileLines, dateIndex) {
|
|
1282
|
+
let end = dateIndex + 1;
|
|
1283
|
+
for (let i = dateIndex + 1; i < fileLines.length; i += 1) {
|
|
1284
|
+
const line = fileLines[i];
|
|
1285
|
+
if (!line.trim()) continue;
|
|
1286
|
+
if (ENTRY_MARKER_RE.test(line.trim()) || /^\s/.test(line)) {
|
|
1287
|
+
end = i + 1;
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
break;
|
|
1291
|
+
}
|
|
1292
|
+
return end;
|
|
1293
|
+
}
|
|
1219
1294
|
function insertDatedBlock(fileLines, day, block) {
|
|
1220
1295
|
const dateLineRe = /^\+ (\d{4}-\d{2}-\d{2})\s*$/;
|
|
1221
1296
|
const dates = [];
|
|
@@ -1225,22 +1300,14 @@ function insertDatedBlock(fileLines, day, block) {
|
|
|
1225
1300
|
}
|
|
1226
1301
|
const existing = dates.find((entry) => entry.date === day);
|
|
1227
1302
|
if (existing) {
|
|
1228
|
-
const
|
|
1229
|
-
let insertAt2 = fileLines.length;
|
|
1230
|
-
for (let i = existing.index + 1; i < fileLines.length; i += 1) {
|
|
1231
|
-
if (boundaryRe.test(fileLines[i])) {
|
|
1232
|
-
insertAt2 = i;
|
|
1233
|
-
break;
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
while (insertAt2 - 1 > existing.index && fileLines[insertAt2 - 1].trim() === "") insertAt2 -= 1;
|
|
1303
|
+
const insertAt2 = datedSectionEnd(fileLines, existing.index);
|
|
1237
1304
|
const trailing = fileLines.slice(insertAt2);
|
|
1238
1305
|
const inserted = [...block];
|
|
1239
1306
|
if (trailing.length > 0 && trailing[0].trim() !== "") inserted.push("");
|
|
1240
1307
|
return [...fileLines.slice(0, insertAt2), ...inserted, ...trailing];
|
|
1241
1308
|
}
|
|
1242
1309
|
const ascending = dates.length < 2 || dates[0].date <= dates[dates.length - 1].date;
|
|
1243
|
-
let insertAt = fileLines.length;
|
|
1310
|
+
let insertAt = dates.length > 0 ? datedSectionEnd(fileLines, dates[dates.length - 1].index) : fileLines.length;
|
|
1244
1311
|
for (const entry of dates) {
|
|
1245
1312
|
if (ascending ? entry.date > day : entry.date < day) {
|
|
1246
1313
|
insertAt = entry.index;
|
|
@@ -1528,7 +1595,19 @@ function getShellCommand(input) {
|
|
|
1528
1595
|
async function writeFileAtomic(file, text) {
|
|
1529
1596
|
const temp = `${file}.${process.pid}.tmp`;
|
|
1530
1597
|
await fs.writeFile(temp, text, "utf8");
|
|
1531
|
-
|
|
1598
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1599
|
+
try {
|
|
1600
|
+
await fs.rename(temp, file);
|
|
1601
|
+
return;
|
|
1602
|
+
} catch (error) {
|
|
1603
|
+
if (attempt >= 5 || !["EPERM", "EACCES", "EBUSY"].includes(error?.code)) {
|
|
1604
|
+
await fs.unlink(temp).catch(() => {
|
|
1605
|
+
});
|
|
1606
|
+
throw error;
|
|
1607
|
+
}
|
|
1608
|
+
await sleep(20 * (attempt + 1));
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1532
1611
|
}
|
|
1533
1612
|
function excerptMultiline(text, limit) {
|
|
1534
1613
|
const value = String(text || "").replace(/\r\n/g, "\n").trim();
|
package/docs/en/extras.md
CHANGED
|
@@ -78,14 +78,15 @@ npx -y @kairyou/agent-tools@latest log -a claude codex opencode
|
|
|
78
78
|
```markdown
|
|
79
79
|
+ 2026-08-03
|
|
80
80
|
<!-- log:2026-08-03:start -->
|
|
81
|
-
1. project-a:
|
|
82
|
-
2. project-a:
|
|
83
|
-
3. project-b:
|
|
81
|
+
1. project-a: Found the cause of the login timeout: the renewal branch never updated the cache expiry, so the second request still read the old value. Patched session.ts and verified...
|
|
82
|
+
2. project-a: Fix is in, with 3 regression tests covering the renewal path; all passing.
|
|
83
|
+
3. project-b: Empty report exports came from an inverted permission filter, now corrected and confirmed working.
|
|
84
84
|
<!-- log:2026-08-03:end -->
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
One line per turn,
|
|
88
|
-
cross-turn consolidation. Updates likewise
|
|
87
|
+
One line per turn, taken verbatim from the closing text of that turn's AI reply
|
|
88
|
+
(truncated when long); no distilling, no cross-turn consolidation. Updates likewise
|
|
89
|
+
rewrite only what sits between the markers.
|
|
89
90
|
|
|
90
91
|
`detailed` output example (excerpt):
|
|
91
92
|
|
package/docs/zh-CN/extras.md
CHANGED
|
@@ -76,14 +76,14 @@ npx -y @kairyou/agent-tools@latest log -a claude codex opencode
|
|
|
76
76
|
```markdown
|
|
77
77
|
+ 2026-08-03
|
|
78
78
|
<!-- log:2026-08-03:start -->
|
|
79
|
-
1. project-a:
|
|
80
|
-
2. project-a:
|
|
81
|
-
3. project-b:
|
|
79
|
+
1. project-a: 已定位登录超时的原因: 会话缓存在续期分支上没有更新过期时间, 第二次请求拿到的还是旧值。已经在 session.ts 补上续期并本地验证通过, 接下来...
|
|
80
|
+
2. project-a: 修复完成, 新增 3 个回归用例覆盖续期路径, 全部通过。
|
|
81
|
+
3. project-b: 报表导出为空定位到权限过滤条件写反, 已修正并确认导出恢复正常。
|
|
82
82
|
<!-- log:2026-08-03:end -->
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
每轮一行,
|
|
86
|
-
|
|
85
|
+
每轮一行, 直接摘录那一轮 AI 回复的收尾内容(过长会截断), 不做提炼也不跨轮归纳;
|
|
86
|
+
同样只重写标记之间的部分, 文件里的其他内容不会被碰.
|
|
87
87
|
|
|
88
88
|
`detailed` 输出示例(节选):
|
|
89
89
|
|
|
@@ -13,12 +13,10 @@
|
|
|
13
13
|
// recorded, and an entry may override format/output/language
|
|
14
14
|
//
|
|
15
15
|
// State and diff snapshots live in ~/.agent-tools/cache/log/ and only the
|
|
16
|
-
// current day is kept.
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// The opencode adapter serializes its own sends, so single-session events
|
|
21
|
-
// there never race each other.
|
|
16
|
+
// current day is kept. Sessions across agents share that state, so each run
|
|
17
|
+
// takes ~/.agent-tools/cache/log/<date>.lock for the whole read-update-render
|
|
18
|
+
// cycle; an event that cannot get the lock within seconds is dropped rather
|
|
19
|
+
// than corrupting the day.
|
|
22
20
|
|
|
23
21
|
import { spawnSync } from "node:child_process";
|
|
24
22
|
import fs from "node:fs/promises";
|
|
@@ -67,22 +65,89 @@ async function main() {
|
|
|
67
65
|
const snapshotsRoot = path.join(CACHE_ROOT, `${day}.snapshots`);
|
|
68
66
|
|
|
69
67
|
await fs.mkdir(CACHE_ROOT, { recursive: true });
|
|
70
|
-
await cleanupCache(day);
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
69
|
+
// Read, update and render under one lock: concurrent sessions across agents
|
|
70
|
+
// otherwise overwrite each other's turns, and on Windows their simultaneous
|
|
71
|
+
// renames fail outright with EPERM.
|
|
72
|
+
const release = await acquireLock(path.join(CACHE_ROOT, `${day}.lock`));
|
|
73
|
+
if (!release) {
|
|
74
|
+
await debugLog(`dropped ${eventName}: could not acquire ${day}.lock`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await cleanupCache(day);
|
|
79
|
+
const state = await loadState(statePath, day);
|
|
80
|
+
await handleEvent(state, hookInput, {
|
|
81
|
+
now,
|
|
78
82
|
snapshotsRoot,
|
|
79
|
-
|
|
83
|
+
effective,
|
|
80
84
|
scopeKey,
|
|
85
|
+
scopePath: scope?.path || "",
|
|
81
86
|
});
|
|
82
|
-
await
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
await writeFileAtomic(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
88
|
+
|
|
89
|
+
if (effective.format === "detailed") {
|
|
90
|
+
const report = await renderDetailedReport(state, {
|
|
91
|
+
snapshotsRoot,
|
|
92
|
+
language: effective.language,
|
|
93
|
+
scopeKey,
|
|
94
|
+
});
|
|
95
|
+
await fs.mkdir(effective.output, { recursive: true });
|
|
96
|
+
await writeFileAtomic(path.join(effective.output, `${day}.md`), report);
|
|
97
|
+
} else {
|
|
98
|
+
await updateDailyFile(effective.output, day, buildDailyItems(state, scopeKey));
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
await release();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- Cross-process lock. ----
|
|
106
|
+
|
|
107
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
108
|
+
const LOCK_RETRY_MS = 25;
|
|
109
|
+
// A hook run is short; a lock older than this belongs to a process that died.
|
|
110
|
+
const LOCK_STALE_MS = 30_000;
|
|
111
|
+
|
|
112
|
+
async function acquireLock(lockPath) {
|
|
113
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
114
|
+
while (Date.now() < deadline) {
|
|
115
|
+
try {
|
|
116
|
+
const handle = await fs.open(lockPath, "wx");
|
|
117
|
+
await handle.close();
|
|
118
|
+
return async () => {
|
|
119
|
+
try {
|
|
120
|
+
await fs.unlink(lockPath);
|
|
121
|
+
} catch {
|
|
122
|
+
// Already reclaimed as stale by another process.
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error?.code !== "EEXIST") return null;
|
|
127
|
+
const age = await fs
|
|
128
|
+
.stat(lockPath)
|
|
129
|
+
.then(({ mtimeMs }) => Date.now() - mtimeMs)
|
|
130
|
+
.catch(() => 0);
|
|
131
|
+
if (age > LOCK_STALE_MS) {
|
|
132
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
await sleep(LOCK_RETRY_MS);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sleep(ms) {
|
|
142
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function debugLog(message) {
|
|
146
|
+
if (process.env.AGENT_TOOLS_LOG_DEBUG !== "1") return;
|
|
147
|
+
try {
|
|
148
|
+
await fs.appendFile(path.join(CACHE_ROOT, "debug.log"), `${new Date().toISOString()} ${message}\n`);
|
|
149
|
+
} catch {
|
|
150
|
+
// Diagnostics must never break logging.
|
|
86
151
|
}
|
|
87
152
|
}
|
|
88
153
|
|
|
@@ -170,9 +235,16 @@ async function handleEvent(state, input, context) {
|
|
|
170
235
|
session.last_time = timestamp;
|
|
171
236
|
|
|
172
237
|
if (eventName === "UserPromptSubmit") {
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
//
|
|
238
|
+
const last = session.turns[session.turns.length - 1];
|
|
239
|
+
// A Stop that arrived before its prompt left a turn holding only an
|
|
240
|
+
// outcome; fill this prompt in rather than splitting one exchange in two.
|
|
241
|
+
if (last && !last.request_text && last.result_summary) {
|
|
242
|
+
last.request_text = excerptMultiline(getPromptText(input), 1600);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// Otherwise always start a turn, even for greetings: reusing the previous
|
|
246
|
+
// turn would let this prompt's Stop overwrite the previous result summary.
|
|
247
|
+
// Trivial turns are filtered at render time instead.
|
|
176
248
|
session.turns.push(
|
|
177
249
|
createTurn(session, timestamp, getPromptText(input), resolveProjectLabel(input, context.scopePath), context.scopeKey)
|
|
178
250
|
);
|
|
@@ -231,7 +303,7 @@ async function cleanupCache(day) {
|
|
|
231
303
|
}
|
|
232
304
|
await Promise.all(
|
|
233
305
|
entries.map(async (entry) => {
|
|
234
|
-
const isState = entry.isFile() && /^\d{4}-\d{2}-\d{2}\.state\.json$/.test(entry.name);
|
|
306
|
+
const isState = entry.isFile() && /^\d{4}-\d{2}-\d{2}\.(state\.json|lock)$/.test(entry.name);
|
|
235
307
|
const isSnapshots = entry.isDirectory() && /^\d{4}-\d{2}-\d{2}\.snapshots$/.test(entry.name);
|
|
236
308
|
if ((!isState && !isSnapshots) || entry.name.startsWith(day)) return;
|
|
237
309
|
try {
|
|
@@ -448,36 +520,63 @@ async function updateDailyFile(outputFile, day, items) {
|
|
|
448
520
|
);
|
|
449
521
|
const block = [`<!-- log:${day}:start -->`, ...lines, `<!-- log:${day}:end -->`];
|
|
450
522
|
|
|
451
|
-
let
|
|
523
|
+
let raw = "";
|
|
452
524
|
try {
|
|
453
|
-
|
|
525
|
+
raw = await fs.readFile(outputFile, "utf8");
|
|
454
526
|
} catch {
|
|
455
527
|
// First write creates the file.
|
|
456
528
|
}
|
|
529
|
+
// Windows editors and PowerShell write a BOM; strip it for parsing and put
|
|
530
|
+
// it back on write so the file keeps the encoding its author chose.
|
|
531
|
+
const hadBom = raw.charCodeAt(0) === 0xfeff;
|
|
532
|
+
const current = hadBom ? raw.slice(1) : raw;
|
|
457
533
|
const eol = current.includes("\r\n") ? "\r\n" : "\n";
|
|
458
534
|
const fileLines = current ? current.split(/\r?\n/) : [];
|
|
459
535
|
|
|
460
536
|
const startMarker = `<!-- log:${day}:start -->`;
|
|
461
537
|
const endMarker = `<!-- log:${day}:end -->`;
|
|
462
|
-
|
|
463
|
-
|
|
538
|
+
// Pair with the LAST start: an earlier damaged start must not swallow
|
|
539
|
+
// whatever the user wrote between it and our block.
|
|
540
|
+
const startIndex = fileLines.findLastIndex((line) => line.trim() === startMarker);
|
|
541
|
+
const endIndex = fileLines.findIndex(
|
|
542
|
+
(line, index) => index > startIndex && line.trim() === endMarker
|
|
543
|
+
);
|
|
464
544
|
|
|
465
545
|
let nextLines;
|
|
466
546
|
if (startIndex !== -1 && endIndex > startIndex) {
|
|
467
547
|
nextLines = [...fileLines.slice(0, startIndex), ...block, ...fileLines.slice(endIndex + 1)];
|
|
468
|
-
} else if (startIndex !== -1 || endIndex !== -1) {
|
|
469
|
-
// Unpaired markers: refuse to guess an edit range in an unattended run.
|
|
470
|
-
console.error(`[agent-tools log] Unpaired markers for ${day} in ${outputFile}; skipped.`);
|
|
471
|
-
return;
|
|
472
548
|
} else {
|
|
549
|
+
// Also covers an unpaired marker: never guess its range, just add an
|
|
550
|
+
// intact block. Recording must not stop because one block got damaged;
|
|
551
|
+
// the stray marker stays visible for the user to clean up.
|
|
473
552
|
nextLines = insertDatedBlock(fileLines, day, block);
|
|
474
553
|
}
|
|
475
554
|
|
|
476
|
-
const
|
|
555
|
+
const body = nextLines.join(eol).replace(/(\r?\n)*$/, eol);
|
|
556
|
+
const text = hadBom ? "" + body : body;
|
|
477
557
|
await fs.mkdir(path.dirname(outputFile), { recursive: true });
|
|
478
558
|
await writeFileAtomic(outputFile, text);
|
|
479
559
|
}
|
|
480
560
|
|
|
561
|
+
// Where a dated entry stops: markers and indented items belong to it, anything
|
|
562
|
+
// else below (headings, comment blocks, separators, todo lists) does not. Both
|
|
563
|
+
// marker namespaces count, since at-daily-log may share this file.
|
|
564
|
+
const ENTRY_MARKER_RE = /^<!--\s*(?:daily-)?log:\d{4}-\d{2}-\d{2}:(?:start|end)\b/;
|
|
565
|
+
|
|
566
|
+
function datedSectionEnd(fileLines, dateIndex) {
|
|
567
|
+
let end = dateIndex + 1;
|
|
568
|
+
for (let i = dateIndex + 1; i < fileLines.length; i += 1) {
|
|
569
|
+
const line = fileLines[i];
|
|
570
|
+
if (!line.trim()) continue;
|
|
571
|
+
if (ENTRY_MARKER_RE.test(line.trim()) || /^\s/.test(line)) {
|
|
572
|
+
end = i + 1;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
return end;
|
|
578
|
+
}
|
|
579
|
+
|
|
481
580
|
function insertDatedBlock(fileLines, day, block) {
|
|
482
581
|
const dateLineRe = /^\+ (\d{4}-\d{2}-\d{2})\s*$/;
|
|
483
582
|
const dates = [];
|
|
@@ -488,18 +587,10 @@ function insertDatedBlock(fileLines, day, block) {
|
|
|
488
587
|
|
|
489
588
|
const existing = dates.find((entry) => entry.date === day);
|
|
490
589
|
if (existing) {
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
const
|
|
495
|
-
let insertAt = fileLines.length;
|
|
496
|
-
for (let i = existing.index + 1; i < fileLines.length; i += 1) {
|
|
497
|
-
if (boundaryRe.test(fileLines[i])) {
|
|
498
|
-
insertAt = i;
|
|
499
|
-
break;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
while (insertAt - 1 > existing.index && fileLines[insertAt - 1].trim() === "") insertAt -= 1;
|
|
590
|
+
// The date exists but carries no block of ours yet (user-written, or only
|
|
591
|
+
// at-daily-log's). Append below that date's own lines, never past the
|
|
592
|
+
// notes and todo lists that live further down the file.
|
|
593
|
+
const insertAt = datedSectionEnd(fileLines, existing.index);
|
|
503
594
|
const trailing = fileLines.slice(insertAt);
|
|
504
595
|
const inserted = [...block];
|
|
505
596
|
if (trailing.length > 0 && trailing[0].trim() !== "") inserted.push("");
|
|
@@ -507,8 +598,12 @@ function insertDatedBlock(fileLines, day, block) {
|
|
|
507
598
|
}
|
|
508
599
|
|
|
509
600
|
// Insert a new date at its date-order position; ascending when ambiguous.
|
|
601
|
+
// Falling off the end means this date sorts last, so it goes at the end of
|
|
602
|
+
// the dated section rather than the end of the file: notes, comment blocks
|
|
603
|
+
// and todo lists kept below the log must stay below it.
|
|
510
604
|
const ascending = dates.length < 2 || dates[0].date <= dates[dates.length - 1].date;
|
|
511
|
-
let insertAt =
|
|
605
|
+
let insertAt =
|
|
606
|
+
dates.length > 0 ? datedSectionEnd(fileLines, dates[dates.length - 1].index) : fileLines.length;
|
|
512
607
|
for (const entry of dates) {
|
|
513
608
|
if (ascending ? entry.date > day : entry.date < day) {
|
|
514
609
|
insertAt = entry.index;
|
|
@@ -862,10 +957,23 @@ function getShellCommand(input) {
|
|
|
862
957
|
|
|
863
958
|
// ---- Small helpers. ----
|
|
864
959
|
|
|
960
|
+
// Windows fails the rename with EPERM when another process holds the target
|
|
961
|
+
// open, so a lost race retries briefly instead of dropping the write.
|
|
865
962
|
async function writeFileAtomic(file, text) {
|
|
866
963
|
const temp = `${file}.${process.pid}.tmp`;
|
|
867
964
|
await fs.writeFile(temp, text, "utf8");
|
|
868
|
-
|
|
965
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
966
|
+
try {
|
|
967
|
+
await fs.rename(temp, file);
|
|
968
|
+
return;
|
|
969
|
+
} catch (error) {
|
|
970
|
+
if (attempt >= 5 || !["EPERM", "EACCES", "EBUSY"].includes(error?.code)) {
|
|
971
|
+
await fs.unlink(temp).catch(() => {});
|
|
972
|
+
throw error;
|
|
973
|
+
}
|
|
974
|
+
await sleep(20 * (attempt + 1));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
869
977
|
}
|
|
870
978
|
|
|
871
979
|
function excerptMultiline(text, limit) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kairyou/agent-tools",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.4",
|
|
4
4
|
"description": "Reusable Agent Skills, plus integrations (statusline, provider usage, vision) that install into Codex, Claude Code, and opencode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -103,8 +103,9 @@ the current block and the regenerated one before writing, then replace only that
|
|
|
103
103
|
date's block. If the date exists without markers, append a marked block below the
|
|
104
104
|
user's lines instead of editing them. Do not duplicate the date. Insert a new date
|
|
105
105
|
among the existing dated entries at its date-order position, inferring ascending or
|
|
106
|
-
descending from the dates already present (ascending when that is ambiguous)
|
|
107
|
-
|
|
106
|
+
descending from the dates already present (ascending when that is ambiguous). A date
|
|
107
|
+
that sorts last goes right after the final dated entry, not at the end of the file:
|
|
108
|
+
notes, comment blocks and todo lists kept below the log stay below it. On duplicate or
|
|
108
109
|
unpaired markers, stop and propose the edit instead of writing. When neither Git nor
|
|
109
110
|
the session log shows activity for the day, leave the file unchanged and say so; the
|
|
110
111
|
user can add a manual entry themselves. Recording a range applies these rules to each day's block independently.
|