@nanhara/hara 0.122.0 → 0.122.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.
- package/CHANGELOG.md +54 -0
- package/README.md +24 -11
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +33 -10
- package/dist/agent/touched.js +4 -0
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +141 -12
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/fs-read.js +318 -3
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +197 -11
- package/dist/gateway/discord.js +2 -4
- package/dist/gateway/feishu.js +4 -6
- package/dist/gateway/flows-pending.js +38 -31
- package/dist/gateway/matrix.js +3 -5
- package/dist/gateway/mattermost.js +2 -4
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +121 -73
- package/dist/gateway/signal.js +4 -6
- package/dist/gateway/slack.js +4 -6
- package/dist/gateway/telegram.js +3 -5
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +7 -8
- package/dist/gateway/weixin.js +22 -12
- package/dist/hooks.js +12 -6
- package/dist/index.js +142 -61
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +4 -2
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +2 -1
- package/dist/skills/skills.js +16 -7
- package/dist/tools/builtin.js +53 -27
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +15 -5
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +37 -17
- package/dist/tools/search.js +100 -40
- package/dist/tools/send.js +11 -5
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/config.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join, dirname, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { ensurePrivateHaraState } from "./security/private-state.js";
|
|
5
|
+
import { readVerifiedRegularFileSnapshotSync } from "./fs-read.js";
|
|
6
|
+
import { projectRepositoryTrustedAtStartup } from "./security/project-trust.js";
|
|
4
7
|
const PROVIDER_DEFAULTS = {
|
|
5
8
|
anthropic: { model: "claude-opus-4-8", envKey: "ANTHROPIC_API_KEY" },
|
|
6
9
|
qwen: {
|
|
@@ -35,6 +38,65 @@ export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
|
|
|
35
38
|
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
36
39
|
export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
|
|
37
40
|
const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
41
|
+
const MAX_PROJECT_CONFIG_BYTES = 256 * 1024;
|
|
42
|
+
const KNOWN_CONFIG_KEYS = new Set([
|
|
43
|
+
...CONFIG_KEYS,
|
|
44
|
+
"hooks", "mcpServers", "modelVision", "overlays", "profiles",
|
|
45
|
+
]);
|
|
46
|
+
/** Deliberately narrow: these keys change presentation/model preference, but cannot redirect credentials,
|
|
47
|
+
* execute code, grant tools more authority, or disable a safety layer. Everything else requires a launch-
|
|
48
|
+
* time trust decision so cloning/chdir into a repository never silently changes the process trust boundary. */
|
|
49
|
+
const SAFE_PROJECT_CONFIG_KEYS = new Set(["model", "theme", "vimMode", "autoCompact", "reasoningEffort"]);
|
|
50
|
+
const projectConfigWarnings = new Set();
|
|
51
|
+
function printableConfigKeys(keys) {
|
|
52
|
+
// A repository controls JSON property names too. Only schema names are safe diagnostics; an unknown key
|
|
53
|
+
// may itself contain a copied token and must never be treated as printable metadata.
|
|
54
|
+
const safe = [...new Set(keys.map((key) => (KNOWN_CONFIG_KEYS.has(key) ? key : "<unknown-key>")))].sort();
|
|
55
|
+
const shown = safe.slice(0, 32);
|
|
56
|
+
return `${shown.join(", ")}${safe.length > shown.length ? `, … (+${safe.length - shown.length})` : ""}`;
|
|
57
|
+
}
|
|
58
|
+
function warnProjectConfig(kind, message) {
|
|
59
|
+
if (projectConfigWarnings.has(kind))
|
|
60
|
+
return;
|
|
61
|
+
projectConfigWarnings.add(kind);
|
|
62
|
+
try {
|
|
63
|
+
process.stderr.write(`hara: ${message}\n`);
|
|
64
|
+
}
|
|
65
|
+
catch { /* best effort */ }
|
|
66
|
+
}
|
|
67
|
+
function validSafeProjectValue(key, value) {
|
|
68
|
+
if (key === "model")
|
|
69
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= 256 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
70
|
+
if (key === "theme")
|
|
71
|
+
return value === "dark" || value === "light";
|
|
72
|
+
if (key === "reasoningEffort")
|
|
73
|
+
return REASONING_EFFORTS.includes(value);
|
|
74
|
+
if (key === "vimMode" || key === "autoCompact")
|
|
75
|
+
return typeof value === "boolean" || value === "true" || value === "false";
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
function filterProjectConfig(input) {
|
|
79
|
+
const blocked = Object.keys(input).filter((key) => !SAFE_PROJECT_CONFIG_KEYS.has(key));
|
|
80
|
+
if (projectRepositoryTrustedAtStartup()) {
|
|
81
|
+
if (blocked.length) {
|
|
82
|
+
const names = printableConfigKeys(blocked);
|
|
83
|
+
warnProjectConfig(`trusted:${names}`, `trusted project config enabled for privileged key(s): ${names}.`);
|
|
84
|
+
}
|
|
85
|
+
return input;
|
|
86
|
+
}
|
|
87
|
+
if (blocked.length) {
|
|
88
|
+
const names = printableConfigKeys(blocked);
|
|
89
|
+
warnProjectConfig(`ignored:${names}`, `ignored untrusted project config key(s): ${names}. Set HARA_TRUST_PROJECT_CONFIG=1 before starting hara only for a repository you trust.`);
|
|
90
|
+
}
|
|
91
|
+
const invalid = Object.entries(input)
|
|
92
|
+
.filter(([key, value]) => SAFE_PROJECT_CONFIG_KEYS.has(key) && !validSafeProjectValue(key, value))
|
|
93
|
+
.map(([key]) => key);
|
|
94
|
+
if (invalid.length) {
|
|
95
|
+
const names = printableConfigKeys(invalid);
|
|
96
|
+
warnProjectConfig(`invalid-safe:${names}`, `ignored invalid project config value(s) for key(s): ${names}.`);
|
|
97
|
+
}
|
|
98
|
+
return Object.fromEntries(Object.entries(input).filter(([key, value]) => (SAFE_PROJECT_CONFIG_KEYS.has(key) && validSafeProjectValue(key, value))));
|
|
99
|
+
}
|
|
38
100
|
export function configPath() {
|
|
39
101
|
return join(homedir(), ".hara", "config.json");
|
|
40
102
|
}
|
|
@@ -42,6 +104,7 @@ function configRecord(value) {
|
|
|
42
104
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
43
105
|
}
|
|
44
106
|
export function readRawConfig() {
|
|
107
|
+
ensurePrivateHaraState();
|
|
45
108
|
const p = configPath();
|
|
46
109
|
if (!existsSync(p))
|
|
47
110
|
return {};
|
|
@@ -80,17 +143,78 @@ function nonBlankEnv(value) {
|
|
|
80
143
|
const trimmed = value?.trim();
|
|
81
144
|
return trimmed || undefined;
|
|
82
145
|
}
|
|
83
|
-
|
|
146
|
+
function projectConfigReadFailure(kind) {
|
|
147
|
+
warnProjectConfig(`unsafe-file:${kind}`, `ignored an unsafe project .hara/config.json (${kind}); no project values were loaded.`);
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
/** Nearest project override `.hara/config.json`, searching a canonical cwd up to the repo root. Project
|
|
151
|
+
* configuration is repository input, not private Hara state: its `.hara` parent and final entry must remain
|
|
152
|
+
* ordinary single-link filesystem objects while a bounded O_NOFOLLOW descriptor is read. */
|
|
84
153
|
function readProjectConfig(cwd) {
|
|
85
|
-
let dir
|
|
154
|
+
let dir;
|
|
155
|
+
try {
|
|
156
|
+
dir = realpathSync.native(resolve(cwd));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
dir = resolve(cwd);
|
|
160
|
+
}
|
|
86
161
|
for (;;) {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
162
|
+
const hara = join(dir, ".hara");
|
|
163
|
+
const p = join(hara, "config.json");
|
|
164
|
+
let haraInfo;
|
|
165
|
+
try {
|
|
166
|
+
haraInfo = lstatSync(hara);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error?.code !== "ENOENT")
|
|
170
|
+
return projectConfigReadFailure("unreadable parent");
|
|
171
|
+
}
|
|
172
|
+
if (haraInfo) {
|
|
173
|
+
if (haraInfo.isSymbolicLink())
|
|
174
|
+
return projectConfigReadFailure("symlink parent");
|
|
175
|
+
if (haraInfo.isDirectory()) {
|
|
176
|
+
let fileInfo;
|
|
177
|
+
try {
|
|
178
|
+
const canonicalParent = realpathSync.native(hara);
|
|
179
|
+
if (canonicalParent !== hara)
|
|
180
|
+
return projectConfigReadFailure("non-canonical parent");
|
|
181
|
+
fileInfo = lstatSync(p);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error?.code !== "ENOENT")
|
|
185
|
+
return projectConfigReadFailure("unreadable file");
|
|
186
|
+
}
|
|
187
|
+
if (fileInfo) {
|
|
188
|
+
if (fileInfo.isSymbolicLink())
|
|
189
|
+
return projectConfigReadFailure("symlink file");
|
|
190
|
+
if (!fileInfo.isFile())
|
|
191
|
+
return projectConfigReadFailure("non-regular file");
|
|
192
|
+
try {
|
|
193
|
+
const snapshot = readVerifiedRegularFileSnapshotSync(p, MAX_PROJECT_CONFIG_BYTES, {
|
|
194
|
+
action: "read project config",
|
|
195
|
+
protectSensitive: false,
|
|
196
|
+
rejectHardLinks: true,
|
|
197
|
+
});
|
|
198
|
+
const parentAfter = lstatSync(hara);
|
|
199
|
+
if (!parentAfter.isDirectory()
|
|
200
|
+
|| parentAfter.isSymbolicLink()
|
|
201
|
+
|| parentAfter.dev !== haraInfo.dev
|
|
202
|
+
|| parentAfter.ino !== haraInfo.ino
|
|
203
|
+
|| realpathSync.native(hara) !== hara)
|
|
204
|
+
return projectConfigReadFailure("changed parent");
|
|
205
|
+
return filterProjectConfig(configRecord(JSON.parse(snapshot.text)));
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error?.code === "HARA_HARD_LINKED_FILE")
|
|
209
|
+
return projectConfigReadFailure("hard-linked file");
|
|
210
|
+
if (error?.code === "HARA_FILE_TOO_LARGE")
|
|
211
|
+
return projectConfigReadFailure("oversized file");
|
|
212
|
+
if (/changed while (?:opening|reading)|File changed/i.test(error?.message ?? "")) {
|
|
213
|
+
return projectConfigReadFailure("changed file");
|
|
214
|
+
}
|
|
215
|
+
return projectConfigReadFailure("invalid file");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
94
218
|
}
|
|
95
219
|
}
|
|
96
220
|
if (PROJECT_ROOT_MARKERS.some((m) => existsSync(join(dir, m))))
|
|
@@ -104,7 +228,12 @@ function readProjectConfig(cwd) {
|
|
|
104
228
|
}
|
|
105
229
|
/** Write the config 0600 (it can hold `apiKey`) + tighten an existing file. */
|
|
106
230
|
function persistConfig(p, cfg) {
|
|
107
|
-
|
|
231
|
+
ensurePrivateHaraState();
|
|
232
|
+
mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
|
|
233
|
+
try {
|
|
234
|
+
chmodSync(dirname(p), 0o700);
|
|
235
|
+
}
|
|
236
|
+
catch { /* best effort */ }
|
|
108
237
|
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
109
238
|
try {
|
|
110
239
|
chmodSync(p, 0o600);
|
|
@@ -132,7 +261,7 @@ export function setModelVisionOverride(model, cap) {
|
|
|
132
261
|
persistConfig(p, cfg);
|
|
133
262
|
}
|
|
134
263
|
/**
|
|
135
|
-
* Effective config. Precedence (high→low): env vars > project `.hara/config.json` >
|
|
264
|
+
* Effective config. Precedence (high→low): env vars > allowed/trusted project `.hara/config.json` >
|
|
136
265
|
* named overlay (`overlays.<name>` in global config) > global `~/.hara/config.json`
|
|
137
266
|
* > provider defaults.
|
|
138
267
|
*
|
|
@@ -1,10 +1,36 @@
|
|
|
1
1
|
// Project-context loading (AGENTS.md) — the cross-tool standard read by Codex/Claude Code/OpenClaw.
|
|
2
2
|
// Walks up from cwd to the project root, concatenates AGENTS.md files, caps total size.
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
4
|
import { join, dirname, resolve } from "node:path";
|
|
5
|
+
import { readModelContextBytePrefixSync } from "../fs-read.js";
|
|
5
6
|
const FILENAMES = ["AGENTS.override.md", "AGENTS.md"];
|
|
6
7
|
const ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
7
8
|
const MAX_BYTES = 32 * 1024;
|
|
9
|
+
const SEPARATOR = "\n\n--- project-doc ---\n\n";
|
|
10
|
+
const TRUNCATED = "\n…[truncated to project-context budget]";
|
|
11
|
+
function byteLength(value) {
|
|
12
|
+
return Buffer.byteLength(value, "utf8");
|
|
13
|
+
}
|
|
14
|
+
/** Retain only whole Unicode code points whose UTF-8 encoding fits the remaining byte budget. */
|
|
15
|
+
function utf8Prefix(value, maxBytes) {
|
|
16
|
+
if (maxBytes <= 0)
|
|
17
|
+
return "";
|
|
18
|
+
if (byteLength(value) <= maxBytes)
|
|
19
|
+
return value;
|
|
20
|
+
let used = 0;
|
|
21
|
+
let out = "";
|
|
22
|
+
for (const char of value) {
|
|
23
|
+
const bytes = byteLength(char);
|
|
24
|
+
if (used + bytes > maxBytes)
|
|
25
|
+
break;
|
|
26
|
+
out += char;
|
|
27
|
+
used += bytes;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
function markBudgetTruncation(value) {
|
|
32
|
+
return utf8Prefix(value, MAX_BYTES - byteLength(TRUNCATED)) + TRUNCATED;
|
|
33
|
+
}
|
|
8
34
|
export function findProjectRoot(cwd) {
|
|
9
35
|
let dir = resolve(cwd);
|
|
10
36
|
for (;;) {
|
|
@@ -30,15 +56,28 @@ export function loadAgentsMd(cwd) {
|
|
|
30
56
|
break;
|
|
31
57
|
dir = parent;
|
|
32
58
|
}
|
|
33
|
-
|
|
59
|
+
let combined = "";
|
|
34
60
|
for (const d of chain) {
|
|
35
61
|
for (const name of FILENAMES) {
|
|
36
62
|
const p = join(d, name);
|
|
37
63
|
if (existsSync(p)) {
|
|
38
64
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
const separator = combined ? SEPARATOR : "";
|
|
66
|
+
const header = `<!-- ${name} @ ${d} -->\n`;
|
|
67
|
+
const fixedBytes = byteLength(separator + header);
|
|
68
|
+
const remaining = MAX_BYTES - byteLength(combined) - fixedBytes;
|
|
69
|
+
if (remaining <= byteLength(TRUNCATED))
|
|
70
|
+
return markBudgetTruncation(combined);
|
|
71
|
+
const read = readModelContextBytePrefixSync(p, remaining);
|
|
72
|
+
if (read.binary)
|
|
73
|
+
continue;
|
|
74
|
+
let txt = read.text.trim();
|
|
75
|
+
if (!txt)
|
|
76
|
+
continue;
|
|
77
|
+
if (read.truncated) {
|
|
78
|
+
txt = utf8Prefix(txt, Math.max(0, remaining - byteLength(TRUNCATED))) + TRUNCATED;
|
|
79
|
+
}
|
|
80
|
+
combined += separator + header + utf8Prefix(txt, remaining);
|
|
42
81
|
}
|
|
43
82
|
catch {
|
|
44
83
|
/* ignore unreadable */
|
|
@@ -46,10 +85,6 @@ export function loadAgentsMd(cwd) {
|
|
|
46
85
|
}
|
|
47
86
|
}
|
|
48
87
|
}
|
|
49
|
-
let combined = parts.join("\n\n--- project-doc ---\n\n");
|
|
50
|
-
if (Buffer.byteLength(combined, "utf8") > MAX_BYTES) {
|
|
51
|
-
combined = Buffer.from(combined, "utf8").subarray(0, MAX_BYTES).toString("utf8") + "\n…[truncated]";
|
|
52
|
-
}
|
|
53
88
|
return combined;
|
|
54
89
|
}
|
|
55
90
|
export function hasAgentsMd(cwd) {
|
package/dist/context/mentions.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// @file mentions — expand `@path` references in user input into appended file contents,
|
|
2
2
|
// and provide fuzzy file candidates for REPL tab-completion.
|
|
3
|
-
import { existsSync,
|
|
3
|
+
import { existsSync, lstatSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, resolve } from "node:path";
|
|
5
5
|
import { listProjectFiles, dirPrefixes, walkFiles } from "../fs-walk.js";
|
|
6
6
|
import { fuzzyRank } from "../fuzzy.js";
|
|
7
7
|
import { mediaTypeFor } from "../images.js";
|
|
8
|
-
import {
|
|
8
|
+
import { readModelContextPrefixSync } from "../fs-read.js";
|
|
9
|
+
import { sensitiveFileError } from "../security/sensitive-files.js";
|
|
9
10
|
const MAX_FILE = 50_000;
|
|
10
11
|
// @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
|
|
11
12
|
const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
|
|
@@ -46,15 +47,20 @@ export function expandMentions(input, cwd) {
|
|
|
46
47
|
/** Render one mention as an inline block, or null if it isn't a readable file/dir. */
|
|
47
48
|
function expandRef(ref, cwd) {
|
|
48
49
|
const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
|
|
50
|
+
const denied = sensitiveFileError(abs, "attach");
|
|
51
|
+
if (denied)
|
|
52
|
+
return `\nProtected file \`${ref}\` was not inserted into model context. ${denied}\n`;
|
|
49
53
|
try {
|
|
50
54
|
if (!existsSync(abs))
|
|
51
55
|
return null;
|
|
52
|
-
const st =
|
|
56
|
+
const st = lstatSync(abs);
|
|
57
|
+
if (st.isSymbolicLink())
|
|
58
|
+
return `Referenced \`${ref}\` is a symbolic link — it was not inserted into model context.`;
|
|
53
59
|
if (st.isFile()) {
|
|
54
60
|
// don't inline binary image bytes as text — paste with Ctrl+V (or drag the file in) to attach visually
|
|
55
61
|
if (mediaTypeFor(abs))
|
|
56
62
|
return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
|
|
57
|
-
const prefix =
|
|
63
|
+
const prefix = readModelContextPrefixSync(abs, MAX_FILE);
|
|
58
64
|
if (prefix.binary)
|
|
59
65
|
return `Referenced \`${ref}\` appears to be binary — it was not inserted into the model context.`;
|
|
60
66
|
const txt = prefix.text + (prefix.truncated ? "\n…[truncated]" : "");
|
|
@@ -2,12 +2,36 @@
|
|
|
2
2
|
// AGENTS.md / CLAUDE.md (the local conventions for that package) by appending it to the tool result, so a
|
|
3
3
|
// monorepo's per-package rules reach the model exactly when work moves into that package. Startup already
|
|
4
4
|
// loads root→cwd (agents-md.ts); this covers the subdirs the agent navigates INTO. Each dir loaded once.
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync, statSync } from "node:fs";
|
|
6
6
|
import { join, dirname, resolve, relative, isAbsolute } from "node:path";
|
|
7
7
|
import { findProjectRoot } from "./agents-md.js";
|
|
8
|
+
import { readModelContextBytePrefixSync } from "../fs-read.js";
|
|
8
9
|
const FILENAMES = ["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"];
|
|
9
10
|
const MAX = 8 * 1024;
|
|
11
|
+
const TRUNCATED = "\n…[truncated to subdirectory-context budget]";
|
|
10
12
|
const loaded = new Set(); // dirs whose local doc we've already injected (per process / session)
|
|
13
|
+
function byteLength(value) {
|
|
14
|
+
return Buffer.byteLength(value, "utf8");
|
|
15
|
+
}
|
|
16
|
+
function utf8Prefix(value, maxBytes) {
|
|
17
|
+
if (maxBytes <= 0)
|
|
18
|
+
return "";
|
|
19
|
+
if (byteLength(value) <= maxBytes)
|
|
20
|
+
return value;
|
|
21
|
+
let used = 0;
|
|
22
|
+
let out = "";
|
|
23
|
+
for (const char of value) {
|
|
24
|
+
const bytes = byteLength(char);
|
|
25
|
+
if (used + bytes > maxBytes)
|
|
26
|
+
break;
|
|
27
|
+
out += char;
|
|
28
|
+
used += bytes;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
function markBudgetTruncation(value) {
|
|
33
|
+
return utf8Prefix(value, MAX - byteLength(TRUNCATED)) + TRUNCATED;
|
|
34
|
+
}
|
|
11
35
|
function isDir(p) {
|
|
12
36
|
try {
|
|
13
37
|
return statSync(p).isDirectory();
|
|
@@ -31,7 +55,7 @@ function pathsFrom(input) {
|
|
|
31
55
|
export function subdirHint(input, cwd) {
|
|
32
56
|
const base = resolve(cwd);
|
|
33
57
|
const root = findProjectRoot(cwd);
|
|
34
|
-
|
|
58
|
+
let hint = "";
|
|
35
59
|
for (const raw of pathsFrom(input)) {
|
|
36
60
|
const absPath = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
|
|
37
61
|
const startDir = isDir(absPath) ? absPath : dirname(absPath);
|
|
@@ -58,12 +82,21 @@ export function subdirHint(input, cwd) {
|
|
|
58
82
|
if (!existsSync(fp))
|
|
59
83
|
continue;
|
|
60
84
|
try {
|
|
61
|
-
|
|
85
|
+
const separator = "\n\n";
|
|
86
|
+
const header = `<!-- ${name} @ ${cd} — local conventions for this directory -->\n`;
|
|
87
|
+
const remaining = MAX - byteLength(hint) - byteLength(separator + header);
|
|
88
|
+
if (remaining <= byteLength(TRUNCATED))
|
|
89
|
+
return markBudgetTruncation(hint);
|
|
90
|
+
const read = readModelContextBytePrefixSync(fp, remaining);
|
|
91
|
+
if (read.binary)
|
|
92
|
+
break;
|
|
93
|
+
let txt = read.text.trim();
|
|
62
94
|
if (!txt)
|
|
63
95
|
break;
|
|
64
|
-
if (
|
|
65
|
-
txt = txt.
|
|
66
|
-
|
|
96
|
+
if (read.truncated) {
|
|
97
|
+
txt = utf8Prefix(txt, Math.max(0, remaining - byteLength(TRUNCATED))) + TRUNCATED;
|
|
98
|
+
}
|
|
99
|
+
hint += separator + header + utf8Prefix(txt, remaining);
|
|
67
100
|
}
|
|
68
101
|
catch {
|
|
69
102
|
/* ignore unreadable */
|
|
@@ -72,5 +105,5 @@ export function subdirHint(input, cwd) {
|
|
|
72
105
|
}
|
|
73
106
|
}
|
|
74
107
|
}
|
|
75
|
-
return
|
|
108
|
+
return hint;
|
|
76
109
|
}
|