agent-sh 0.15.0 → 0.15.2

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 (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Skill discovery and loading.
3
+ *
4
+ * Follows the Agent Skills standard (agentskills.io):
5
+ * - Skills are directories containing a SKILL.md with YAML frontmatter
6
+ * - Frontmatter must include `name` and `description`
7
+ * - Full content is loaded on-demand (only names/descriptions in system prompt)
8
+ *
9
+ * Discovery locations:
10
+ * Global: ~/.agent-sh/skills/ (default), plus skillPaths from settings
11
+ * Project: .agents/skills/ in cwd and ancestor dirs (up to git root)
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import * as os from "node:os";
16
+ import { CONFIG_DIR, getSettings } from "../core/settings.js";
17
+
18
+ export interface Skill {
19
+ name: string;
20
+ description: string;
21
+ filePath: string;
22
+ baseDir: string;
23
+ }
24
+
25
+ /** Parse YAML frontmatter from a SKILL.md file. Supports inline scalars
26
+ * and block scalars (`>`, `>-`, `|`, `|-`) for multi-line descriptions. */
27
+ function parseFrontmatter(content: string): { meta: Record<string, string>; body: string } | null {
28
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
29
+ if (!match) return null;
30
+
31
+ const meta: Record<string, string> = {};
32
+ const lines = match[1].split("\n");
33
+ let i = 0;
34
+ while (i < lines.length) {
35
+ const line = lines[i]!;
36
+ const indent = line.length - line.trimStart().length;
37
+ const colon = line.indexOf(":");
38
+ if (colon <= 0 || indent > 0) { i++; continue; }
39
+ const key = line.slice(0, colon).trim();
40
+ const rawValue = line.slice(colon + 1).trim();
41
+ const blockStyle = rawValue.match(/^([>|])([+-]?)\s*$/);
42
+ if (!blockStyle) {
43
+ meta[key] = rawValue;
44
+ i++;
45
+ continue;
46
+ }
47
+ const folded = blockStyle[1] === ">";
48
+ const chomp = blockStyle[2];
49
+ const body: string[] = [];
50
+ let blockIndent = -1;
51
+ let j = i + 1;
52
+ while (j < lines.length) {
53
+ const next = lines[j]!;
54
+ if (next.trim() === "") { body.push(""); j++; continue; }
55
+ const ind = next.length - next.trimStart().length;
56
+ if (blockIndent === -1) blockIndent = ind;
57
+ if (ind < blockIndent) break;
58
+ body.push(next.slice(blockIndent));
59
+ j++;
60
+ }
61
+ let end = body.length;
62
+ if (chomp !== "+") {
63
+ while (end > 0 && body[end - 1] === "") end--;
64
+ if (chomp !== "-" && end < body.length) end++;
65
+ }
66
+ const kept = body.slice(0, end);
67
+ meta[key] = folded
68
+ ? kept.join(" ").replace(/\s+/g, " ").trim()
69
+ : kept.join("\n");
70
+ i = j;
71
+ }
72
+
73
+ return { meta, body: match[2] };
74
+ }
75
+
76
+ /** Load a single skill from a SKILL.md file. */
77
+ function loadSkillFromFile(filePath: string): Skill | null {
78
+ try {
79
+ const content = fs.readFileSync(filePath, "utf-8");
80
+ const parsed = parseFrontmatter(content);
81
+ if (!parsed) return null;
82
+
83
+ const name = parsed.meta.name;
84
+ const description = parsed.meta.description;
85
+ if (!name || !description) return null;
86
+
87
+ if (parsed.meta["disable-model-invocation"] === "true") return null;
88
+
89
+ return {
90
+ name,
91
+ description,
92
+ filePath,
93
+ baseDir: path.dirname(filePath),
94
+ };
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** Recursively scan a directory for SKILL.md files. */
101
+ function scanDir(dir: string): Skill[] {
102
+ const skills: Skill[] = [];
103
+
104
+ let entries: fs.Dirent[];
105
+ try {
106
+ entries = fs.readdirSync(dir, { withFileTypes: true });
107
+ } catch {
108
+ return skills;
109
+ }
110
+
111
+ // If this directory has a SKILL.md, it's a skill root — don't recurse further
112
+ const skillMd = path.join(dir, "SKILL.md");
113
+ try {
114
+ fs.accessSync(skillMd);
115
+ const skill = loadSkillFromFile(skillMd);
116
+ if (skill) skills.push(skill);
117
+ return skills;
118
+ } catch {
119
+ // No SKILL.md here — check subdirectories
120
+ }
121
+
122
+ for (const entry of entries) {
123
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
124
+
125
+ const fullPath = path.join(dir, entry.name);
126
+ const isDir = entry.isDirectory() ||
127
+ (entry.isSymbolicLink() && (() => { try { return fs.statSync(fullPath).isDirectory(); } catch { return false; } })());
128
+
129
+ if (isDir) {
130
+ skills.push(...scanDir(fullPath));
131
+ }
132
+ }
133
+
134
+ return skills;
135
+ }
136
+
137
+ /** Expand ~ to home directory. */
138
+ function expandHome(p: string): string {
139
+ if (p.startsWith("~/") || p === "~") {
140
+ return path.join(os.homedir(), p.slice(1));
141
+ }
142
+ return p;
143
+ }
144
+
145
+ function addUnique(target: Skill[], source: Skill[], seen: Set<string>): void {
146
+ for (const skill of source) {
147
+ if (!seen.has(skill.name)) {
148
+ seen.add(skill.name);
149
+ target.push(skill);
150
+ }
151
+ }
152
+ }
153
+
154
+ // Global skill sources are stable within a session, so cache the result
155
+ // to skip filesystem scans on every system-prompt:build.
156
+ let _cachedGlobalSkills: Skill[] | null = null;
157
+
158
+ /** Discover global skills (stable across cwd changes). Cached per-process. */
159
+ export function discoverGlobalSkills(): Skill[] {
160
+ if (_cachedGlobalSkills) return _cachedGlobalSkills;
161
+
162
+ const seen = new Set<string>();
163
+ const skills: Skill[] = [];
164
+
165
+ addUnique(skills, scanDir(path.join(CONFIG_DIR, "skills")), seen);
166
+
167
+ const settings = getSettings();
168
+ for (const p of settings.skillPaths ?? []) {
169
+ addUnique(skills, scanDir(path.resolve(expandHome(p))), seen);
170
+ }
171
+
172
+ _cachedGlobalSkills = skills;
173
+ return skills;
174
+ }
175
+
176
+ export function invalidateGlobalSkillsCache(): void {
177
+ _cachedGlobalSkills = null;
178
+ }
179
+
180
+ /**
181
+ * Discover project-level skills from .agents/skills/ in cwd hierarchy.
182
+ * Walks from cwd up to $HOME (or filesystem root if cwd is outside HOME).
183
+ * Git boundaries are ignored — nested repos under a skills-bearing parent
184
+ * would otherwise hide the parent's skills.
185
+ */
186
+ export function discoverProjectSkills(cwd: string): Skill[] {
187
+ const seen = new Set<string>();
188
+ const skills: Skill[] = [];
189
+ const home = path.resolve(os.homedir());
190
+ let current = path.resolve(cwd);
191
+
192
+ while (true) {
193
+ addUnique(skills, scanDir(path.join(current, ".agents", "skills")), seen);
194
+ if (current === home) break;
195
+ const parent = path.dirname(current);
196
+ if (parent === current) break;
197
+ current = parent;
198
+ }
199
+
200
+ return skills;
201
+ }
202
+
203
+ /**
204
+ * Discover all skills (global + project).
205
+ */
206
+ export function discoverSkills(cwd: string): Skill[] {
207
+ const seen = new Set<string>();
208
+ const skills: Skill[] = [];
209
+ addUnique(skills, discoverGlobalSkills(), seen);
210
+ addUnique(skills, discoverProjectSkills(cwd), seen);
211
+ return skills;
212
+ }
213
+
214
+ /**
215
+ * Load the full content of a skill (frontmatter stripped).
216
+ * Returns XML-wrapped content suitable for injection into conversation.
217
+ */
218
+ export function loadSkillContent(skill: Skill): string | null {
219
+ try {
220
+ const content = fs.readFileSync(skill.filePath, "utf-8");
221
+ const parsed = parseFrontmatter(content);
222
+ if (!parsed) return content;
223
+
224
+ return `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${parsed.body.trim()}\n</skill>`;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
@@ -0,0 +1,310 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import * as crypto from "node:crypto";
5
+
6
+ export interface Entry {
7
+ id: string;
8
+ parentId?: string;
9
+ ts: number;
10
+ kind: string;
11
+ payload: Record<string, unknown>;
12
+ }
13
+
14
+ export interface AppendOpts {
15
+ /** Memory-only; never persisted. */
16
+ ephemeral?: boolean;
17
+ }
18
+
19
+ export interface SearchHit {
20
+ entry: Entry;
21
+ line: string;
22
+ }
23
+
24
+ /** Append-only — no edit or delete. Implementations may apply bulk
25
+ * retention (front-truncation, GC), but strategies cannot remove a
26
+ * specific entry. */
27
+ export interface Store {
28
+ append(entries: Entry[], opts?: AppendOpts): Promise<void>;
29
+ findById(id: string): Promise<Entry | null>;
30
+ readRecent(n?: number): Promise<Entry[]>;
31
+ search(query: string): Promise<SearchHit[]>;
32
+ }
33
+
34
+ export interface TreeStore extends Store {
35
+ getBranch(leafId?: string): Promise<Entry[]>;
36
+ setLeaf(id: string): void;
37
+ getLeaf(): string;
38
+ }
39
+
40
+ export function newEntryId(): string {
41
+ return crypto.randomBytes(4).toString("hex");
42
+ }
43
+
44
+ export function isTreeStore(s: Store): s is TreeStore {
45
+ return (
46
+ typeof (s as TreeStore).setLeaf === "function" &&
47
+ typeof (s as TreeStore).getLeaf === "function" &&
48
+ typeof (s as TreeStore).getBranch === "function"
49
+ );
50
+ }
51
+
52
+ function escapeRegex(s: string): string {
53
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+ }
55
+
56
+ function compileSearchRegex(query: string): RegExp {
57
+ return new RegExp(escapeRegex(query), "i");
58
+ }
59
+
60
+ function matchEntry(entry: Entry, re: RegExp): SearchHit | null {
61
+ const line = JSON.stringify(entry);
62
+ return re.test(line) ? { entry, line } : null;
63
+ }
64
+
65
+ export class NoopStore implements Store {
66
+ async append(): Promise<void> {}
67
+ async findById(): Promise<Entry | null> { return null; }
68
+ async readRecent(): Promise<Entry[]> { return []; }
69
+ async search(): Promise<SearchHit[]> { return []; }
70
+ }
71
+
72
+ export class InMemoryStore implements TreeStore {
73
+ private entries = new Map<string, Entry>();
74
+ private order: string[] = [];
75
+ private leaf: string;
76
+
77
+ constructor(opts?: { root?: Entry }) {
78
+ if (opts?.root) {
79
+ this.entries.set(opts.root.id, opts.root);
80
+ this.order.push(opts.root.id);
81
+ this.leaf = opts.root.id;
82
+ } else {
83
+ this.leaf = "";
84
+ }
85
+ }
86
+
87
+ async append(entries: Entry[]): Promise<void> {
88
+ for (const e of entries) {
89
+ this.entries.set(e.id, e);
90
+ this.order.push(e.id);
91
+ }
92
+ }
93
+
94
+ async findById(id: string): Promise<Entry | null> {
95
+ return this.entries.get(id) ?? null;
96
+ }
97
+
98
+ async readRecent(n?: number): Promise<Entry[]> {
99
+ const slice = n == null ? this.order : this.order.slice(-n);
100
+ return slice.map((id) => this.entries.get(id)!);
101
+ }
102
+
103
+ async search(query: string): Promise<SearchHit[]> {
104
+ if (!query.trim()) return [];
105
+ const re = compileSearchRegex(query);
106
+ const out: SearchHit[] = [];
107
+ for (let i = this.order.length - 1; i >= 0; i--) {
108
+ const m = matchEntry(this.entries.get(this.order[i]!)!, re);
109
+ if (m) out.push(m);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ async getBranch(leafId: string = this.leaf): Promise<Entry[]> {
115
+ const out: Entry[] = [];
116
+ const seen = new Set<string>();
117
+ let cur: string | undefined = leafId;
118
+ while (cur && !seen.has(cur)) {
119
+ seen.add(cur);
120
+ const e = this.entries.get(cur);
121
+ if (!e) break;
122
+ out.push(e);
123
+ cur = e.parentId;
124
+ }
125
+ return out.reverse();
126
+ }
127
+
128
+ setLeaf(id: string): void {
129
+ if (!this.entries.has(id)) throw new Error(`unknown entry: ${id}`);
130
+ this.leaf = id;
131
+ }
132
+
133
+ getLeaf(): string {
134
+ return this.leaf;
135
+ }
136
+ }
137
+
138
+ /** Multi-writer JSONL Store. O_APPEND with PIPE_BUF-bounded line
139
+ * writes for atomic concurrent appends; lock-based front-truncation
140
+ * for retention; reads stream the tail for cheap recent slices. */
141
+ const LOCK_STALE_MS = 10_000;
142
+ const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
143
+
144
+ export interface SharedFileStoreOpts {
145
+ filePath: string;
146
+ /** Front-truncate above this size; truncation fires at 150% of the
147
+ * cap to avoid frequent rewrites. */
148
+ maxBytes?: number;
149
+ }
150
+
151
+ export class SharedFileStore implements Store {
152
+ private filePath: string;
153
+ private lockPath: string;
154
+ private maxBytes: number;
155
+
156
+ constructor(opts: SharedFileStoreOpts) {
157
+ this.filePath = opts.filePath;
158
+ this.lockPath = opts.filePath + ".lock";
159
+ this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
160
+ try { fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); } catch { /* ignore */ }
161
+ }
162
+
163
+ async append(entries: Entry[], opts?: AppendOpts): Promise<void> {
164
+ if (entries.length === 0) return;
165
+ if (opts?.ephemeral) return; // memory-only writes are a no-op on a file-only store
166
+ const lines = entries.map((e) => JSON.stringify(e) + "\n").join("");
167
+ await fsp.appendFile(this.filePath, lines, { flag: "a" });
168
+ await this.maybeTruncate();
169
+ }
170
+
171
+ async findById(id: string): Promise<Entry | null> {
172
+ for await (const line of this.streamReverseLines()) {
173
+ try {
174
+ const e = JSON.parse(line) as Entry;
175
+ if (e.id === id) return e;
176
+ } catch { /* skip malformed */ }
177
+ }
178
+ return null;
179
+ }
180
+
181
+ async readRecent(n?: number): Promise<Entry[]> {
182
+ const want = n ?? Infinity;
183
+ const recent: Entry[] = []; // newest-first
184
+ for await (const line of this.streamReverseLines()) {
185
+ try {
186
+ const e = JSON.parse(line) as Entry;
187
+ if (!e.id) continue;
188
+ recent.push(e);
189
+ if (recent.length >= want) break;
190
+ } catch { /* skip malformed */ }
191
+ }
192
+ return recent.reverse();
193
+ }
194
+
195
+ async search(query: string): Promise<SearchHit[]> {
196
+ if (!query.trim()) return [];
197
+ const re = compileSearchRegex(query);
198
+ const budgetBytes = 20 * 1024 * 1024;
199
+ let scanned = 0;
200
+ const out: SearchHit[] = [];
201
+ for await (const line of this.streamReverseLines()) {
202
+ scanned += line.length + 1;
203
+ if (scanned > budgetBytes) break;
204
+ try {
205
+ const e = JSON.parse(line) as Entry;
206
+ const m = matchEntry(e, re);
207
+ if (m) out.push(m);
208
+ } catch { /* skip malformed */ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /** Yield lines newest-first by reading reverse-chunked blocks,
214
+ * stitching across boundaries. */
215
+ private async *streamReverseLines(chunkBytes = 1 << 20): AsyncGenerator<string> {
216
+ let handle: fsp.FileHandle;
217
+ let fileSize: number;
218
+ try {
219
+ const stat = await fsp.stat(this.filePath);
220
+ fileSize = stat.size;
221
+ if (fileSize === 0) return;
222
+ handle = await fsp.open(this.filePath, "r");
223
+ } catch {
224
+ return;
225
+ }
226
+ try {
227
+ let position = fileSize;
228
+ let pending: Buffer = Buffer.alloc(0);
229
+ while (position > 0) {
230
+ const readSize = Math.min(chunkBytes, position);
231
+ position -= readSize;
232
+ const buf = Buffer.alloc(readSize);
233
+ await handle.read(buf, 0, readSize, position);
234
+ const combined = Buffer.concat([buf, pending]);
235
+ const newlineIdxs: number[] = [];
236
+ for (let i = 0; i < combined.length; i++) {
237
+ if (combined[i] === 0x0A) newlineIdxs.push(i);
238
+ }
239
+ if (newlineIdxs.length === 0) { pending = combined; continue; }
240
+ const firstNl = newlineIdxs[0]!;
241
+ const lastNl = newlineIdxs[newlineIdxs.length - 1]!;
242
+ const trailing = combined.subarray(lastNl + 1);
243
+ if (trailing.length > 0) yield trailing.toString("utf-8");
244
+ for (let i = newlineIdxs.length - 1; i >= 1; i--) {
245
+ const seg = combined.subarray(newlineIdxs[i - 1]! + 1, newlineIdxs[i]!);
246
+ if (seg.length > 0) yield seg.toString("utf-8");
247
+ }
248
+ const leading = combined.subarray(0, firstNl);
249
+ if (position === 0) {
250
+ if (leading.length > 0) yield leading.toString("utf-8");
251
+ pending = Buffer.alloc(0);
252
+ } else {
253
+ pending = leading;
254
+ }
255
+ }
256
+ if (pending.length > 0) yield pending.toString("utf-8");
257
+ } finally {
258
+ await handle.close();
259
+ }
260
+ }
261
+
262
+ private async maybeTruncate(): Promise<void> {
263
+ let size = 0;
264
+ try { size = (await fsp.stat(this.filePath)).size; } catch { return; }
265
+ if (size <= this.maxBytes * 1.5) return;
266
+
267
+ if (!(await this.acquireLock())) return;
268
+ try {
269
+ let content: string;
270
+ try { content = await fsp.readFile(this.filePath, "utf-8"); }
271
+ catch { return; }
272
+
273
+ const lines = content.split("\n").filter(Boolean);
274
+ let totalBytes = Buffer.byteLength(content, "utf-8");
275
+ let dropCount = 0;
276
+ while (totalBytes > this.maxBytes && dropCount < lines.length - 1) {
277
+ totalBytes -= Buffer.byteLength(lines[dropCount]! + "\n", "utf-8");
278
+ dropCount++;
279
+ }
280
+ if (dropCount === 0) return;
281
+
282
+ const remaining = lines.slice(dropCount).join("\n") + "\n";
283
+ const tmpPath = this.filePath + ".tmp." + process.pid;
284
+ await fsp.writeFile(tmpPath, remaining);
285
+ await fsp.rename(tmpPath, this.filePath);
286
+ } finally {
287
+ await this.releaseLock();
288
+ }
289
+ }
290
+
291
+ private async acquireLock(): Promise<boolean> {
292
+ try {
293
+ try {
294
+ const stat = await fsp.stat(this.lockPath);
295
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
296
+ await fsp.unlink(this.lockPath).catch(() => {});
297
+ }
298
+ } catch { /* lock absent — good */ }
299
+ const fd = await fsp.open(this.lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
300
+ await fd.close();
301
+ return true;
302
+ } catch {
303
+ return false;
304
+ }
305
+ }
306
+
307
+ private async releaseLock(): Promise<void> {
308
+ await fsp.unlink(this.lockPath).catch(() => {});
309
+ }
310
+ }