@ngockhoale/ukit 1.4.0 → 1.4.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.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/bug/triageBug.js +1 -33
- package/src/cli/commands/install.js +5 -10
- package/src/context/detectProjectContext.js +3 -24
- package/src/core/compact/index.js +19 -27
- package/src/core/ensureGitignore.js +1 -1
- package/src/core/fileOps.js +41 -2
- package/src/core/memory/hygiene.js +17 -1
- package/src/core/memory/store.js +14 -36
- package/src/core/metadata.js +5 -5
- package/src/core/output/index.js +20 -20
- package/src/core/packageManager.js +51 -0
- package/src/core/router/router.js +22 -6
- package/src/core/runInstallPipeline.js +1 -36
- package/src/core/runtimeConfig.js +71 -3
- package/src/core/token/index.js +21 -1
- package/src/core/uninstall.js +15 -38
- package/src/index/buildIndex.js +217 -49
- package/src/index/gitHooks.js +32 -7
- package/src/index/impactContext.js +16 -6
- package/src/index/importResolution.js +105 -28
- package/src/index/paths.js +29 -0
- package/src/index/queryIndex.js +20 -35
- package/src/index/relatedTests.js +15 -2
- package/src/index/routeCatalog.js +1 -1
- package/src/index/taskRouting.js +438 -18
- package/src/index/verificationPlan.js +2 -36
- package/templates/.claude/hooks/reinject-context.sh +2 -0
- package/templates/.claude/hooks/session-start.md +2 -0
- package/templates/.claude/hooks/skill-router.sh +657 -15
- package/templates/.claude/ukit/index/route-catalog.mjs +1 -1
- package/templates/.claude/ukit/index/route-task.mjs +475 -5
- package/templates/.claude/ukit/runtime/reinject-context.mjs +120 -3
- package/templates/.codex/README.md +8 -1
- package/templates/.codex/settings.json +53 -0
- package/templates/AGENTS.md +3 -0
- package/templates/CLAUDE.md +5 -1
- package/templates/ukit/README.md +1 -1
- package/templates/ukit/storage/config.json +61 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ All notable changes to UKit are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 1.4.2 - 2026-05-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added a quality-first internal orchestration ladder with seven execution layers: `tiny-fix`, `local-fix`, `local-build`, `find-cause`, `shared-edit`, `map-impact`, and `review-release`.
|
|
12
|
+
- Added structured route continuity state so installed helpers and hooks can carry `completionState`, `continuationState`, and stuck-lane rescue signals across turns instead of forgetting unfinished execution debt.
|
|
13
|
+
- Added orchestration/runtime config defaults and Codex adapter metadata for the internal advisor/orchestrator contract while keeping the teammate workflow centered on `ukit install`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Kept context routing and context-mode hints internal so UKit no longer surfaces `pull-indexed-context`, `resolve-context`, or `mode=LITE/FULL` in the main route prose for ordinary work.
|
|
18
|
+
- Preserved the structured route state (`nextActionType`, `helperHint`, `contextMode`) for internal orchestration, while removing the user/model-facing leakage that was nudging sessions into micro-step stop-and-wait behavior.
|
|
19
|
+
- Hardened implementation routing and installed agent guidance so explicit implement/apply/fix requests keep moving through bounded read → edit → verify flow instead of stopping after read-only inspection.
|
|
20
|
+
- Escalated repeated unfinished returns with `repeatCount`, `stuckRisk`, and `rescueMode` so UKit can rescue a stuck lane instead of quietly looping the same partial milestone.
|
|
21
|
+
- Fixed the installed `route-task` continuation handoff so previous route summaries are carried through correctly during rescue escalation.
|
|
22
|
+
|
|
23
|
+
### Tests
|
|
24
|
+
|
|
25
|
+
- Added/updated route, hook, reinject, install-pipeline, and package-artifact coverage for internal helper hiding, continuation rescue escalation, and the expanded orchestration contract.
|
|
26
|
+
|
|
27
|
+
## 1.4.1 - 2026-05-09
|
|
28
|
+
|
|
29
|
+
- Completed cleanup and 1.4.1 template alignment.
|
|
30
|
+
|
|
7
31
|
## 1.4.0 - 2026-05-09
|
|
8
32
|
|
|
9
33
|
### Added
|
package/package.json
CHANGED
package/src/bug/triageBug.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { queryCodeIndex } from '../index/queryIndex.js';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { detectPackageManager } from '../core/packageManager.js';
|
|
4
5
|
import { inferRelatedTestsFromArtifacts, loadRelatedTestArtifacts } from '../index/relatedTests.js';
|
|
5
6
|
|
|
6
7
|
const DEEP_KEYWORDS = ['race', 'flaky', 'intermittent', 'timeout', 'deadlock'];
|
|
@@ -88,36 +89,3 @@ async function buildTestCommand(rootDir, testFile) {
|
|
|
88
89
|
// npm default
|
|
89
90
|
return `npm test -- ${testFile}`;
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
-
async function detectPackageManager(rootDir) {
|
|
93
|
-
const packageJsonPath = path.join(rootDir, 'package.json');
|
|
94
|
-
try {
|
|
95
|
-
const raw = await fs.readFile(packageJsonPath, 'utf8');
|
|
96
|
-
const pkg = JSON.parse(raw);
|
|
97
|
-
const declared = String(pkg?.packageManager ?? '').toLowerCase();
|
|
98
|
-
if (declared.startsWith('pnpm')) return 'pnpm';
|
|
99
|
-
if (declared.startsWith('yarn')) return 'yarn';
|
|
100
|
-
if (declared.startsWith('bun')) return 'bun';
|
|
101
|
-
if (declared.startsWith('npm')) return 'npm';
|
|
102
|
-
} catch {
|
|
103
|
-
// fall through to lockfile detection
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const checks = [
|
|
107
|
-
['pnpm-lock.yaml', 'pnpm'],
|
|
108
|
-
['yarn.lock', 'yarn'],
|
|
109
|
-
['bun.lockb', 'bun'],
|
|
110
|
-
['package-lock.json', 'npm'],
|
|
111
|
-
];
|
|
112
|
-
|
|
113
|
-
for (const [lockfile, pm] of checks) {
|
|
114
|
-
try {
|
|
115
|
-
await fs.access(path.join(rootDir, lockfile));
|
|
116
|
-
return pm;
|
|
117
|
-
} catch {
|
|
118
|
-
// continue
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return 'npm';
|
|
123
|
-
}
|
|
@@ -106,6 +106,9 @@ export async function pruneDeselectedAdapters({
|
|
|
106
106
|
createInterface = readline.createInterface,
|
|
107
107
|
}) {
|
|
108
108
|
const retainedManagedPaths = [];
|
|
109
|
+
const retainPaths = (paths) => {
|
|
110
|
+
retainedManagedPaths.push(...paths);
|
|
111
|
+
};
|
|
109
112
|
const installMetaPath = path.join(projectRoot, '.claude', 'ukit', '.ukit', 'install.json');
|
|
110
113
|
const deselectedKeys = getDeselectedAdapterKeys(optionalToolKeys);
|
|
111
114
|
if (deselectedKeys.length === 0) {
|
|
@@ -139,11 +142,7 @@ export async function pruneDeselectedAdapters({
|
|
|
139
142
|
const { input: inputStream, output: outputStream } = io;
|
|
140
143
|
if (!inputStream?.isTTY || !outputStream?.isTTY) {
|
|
141
144
|
const adapterNames = existingDeselectedAdapters.map(({ adapter }) => adapter.label).join(', ');
|
|
142
|
-
|
|
143
|
-
installMetaPath,
|
|
144
|
-
projectRoot,
|
|
145
|
-
removeRelativePaths: existingDeselectedAdapters.flatMap(({ existingManagedPaths }) => existingManagedPaths),
|
|
146
|
-
});
|
|
145
|
+
retainPaths(existingDeselectedAdapters.flatMap(({ existingManagedPaths }) => existingManagedPaths));
|
|
147
146
|
console.log(
|
|
148
147
|
`[UKit] Deselected adapters with existing files detected (${adapterNames}). Skipping removal in non-interactive mode.`,
|
|
149
148
|
);
|
|
@@ -160,11 +159,7 @@ export async function pruneDeselectedAdapters({
|
|
|
160
159
|
);
|
|
161
160
|
|
|
162
161
|
if (!shouldRemove) {
|
|
163
|
-
|
|
164
|
-
installMetaPath,
|
|
165
|
-
projectRoot,
|
|
166
|
-
removeRelativePaths: existingManagedPaths,
|
|
167
|
-
});
|
|
162
|
+
retainPaths(existingManagedPaths);
|
|
168
163
|
continue;
|
|
169
164
|
}
|
|
170
165
|
|
|
@@ -1,27 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
async function inferPackageManager(projectRoot, packageJson) {
|
|
5
|
-
// packageManager field takes priority over lockfiles — it is an explicit
|
|
6
|
-
// declaration of intent, while lockfiles may be stale (e.g. old yarn.lock
|
|
7
|
-
// leftover after switching to npm).
|
|
8
|
-
const declared = packageJson?.packageManager;
|
|
9
|
-
if (typeof declared === 'string') {
|
|
10
|
-
if (declared.startsWith('yarn@')) return 'yarn';
|
|
11
|
-
if (declared.startsWith('pnpm@')) return 'pnpm';
|
|
12
|
-
if (declared.startsWith('npm@')) return 'npm';
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
if (await pathExists(path.join(projectRoot, 'yarn.lock'))) {
|
|
16
|
-
return 'yarn';
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (await pathExists(path.join(projectRoot, 'pnpm-lock.yaml'))) {
|
|
20
|
-
return 'pnpm';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return 'npm';
|
|
24
|
-
}
|
|
2
|
+
import { readJsonIfExists } from '../core/fileOps.js';
|
|
3
|
+
import { detectPackageManager } from '../core/packageManager.js';
|
|
25
4
|
|
|
26
5
|
function inferProjectName(projectRoot, packageJson) {
|
|
27
6
|
if (typeof packageJson?.name === 'string' && packageJson.name.trim() !== '') {
|
|
@@ -41,7 +20,7 @@ export async function detectProjectContext(projectRoot) {
|
|
|
41
20
|
name: inferProjectName(projectRoot, packageJson),
|
|
42
21
|
},
|
|
43
22
|
runtime: {
|
|
44
|
-
packageManager: await
|
|
23
|
+
packageManager: await detectPackageManager(projectRoot, packageJson),
|
|
45
24
|
os: process.platform,
|
|
46
25
|
nodeVersion: process.version,
|
|
47
26
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
2
2
|
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
3
|
-
import { compressLine, compressMarkdownLines, estimateTokenCount } from '../token/index.js';
|
|
3
|
+
import { compressLine, compressMarkdownLines, estimateTokenCount, normalizeLineForDedupe, uniqueLines } from '../token/index.js';
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_COMPACT_HISTORY_MAX_ENTRIES = 50;
|
|
6
6
|
const DEFAULT_MAX_COMPACT_ANCHORS = 3;
|
|
@@ -132,27 +132,6 @@ function normalizeAnchorPattern(pattern) {
|
|
|
132
132
|
return null;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
function normalizeLineForDedupe(line) {
|
|
136
|
-
return String(line ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function uniqueLines(lines) {
|
|
140
|
-
const seen = new Set();
|
|
141
|
-
const unique = [];
|
|
142
|
-
|
|
143
|
-
for (const line of lines) {
|
|
144
|
-
const trimmed = String(line ?? '').trim();
|
|
145
|
-
const normalized = normalizeLineForDedupe(trimmed);
|
|
146
|
-
if (!normalized || seen.has(normalized)) {
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
seen.add(normalized);
|
|
150
|
-
unique.push(trimmed);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return unique;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
135
|
function resolveAnchorLines(rawLines, { header = null, anchorLines = [], anchorPatterns = [], maxAnchors = DEFAULT_MAX_COMPACT_ANCHORS } = {}) {
|
|
157
136
|
const predicates = anchorPatterns
|
|
158
137
|
.map((pattern) => normalizeAnchorPattern(pattern))
|
|
@@ -182,6 +161,7 @@ function buildCompactedContextLines(
|
|
|
182
161
|
forceFirstCount = 0,
|
|
183
162
|
} = {},
|
|
184
163
|
) {
|
|
164
|
+
const hardTokenCap = Math.max(maxTokens, Math.ceil(maxTokens * 2));
|
|
185
165
|
const sourceLines = Array.isArray(lines) ? lines : [];
|
|
186
166
|
const selected = [];
|
|
187
167
|
const seen = new Set();
|
|
@@ -201,19 +181,24 @@ function buildCompactedContextLines(
|
|
|
201
181
|
|
|
202
182
|
const tokens = estimateTokenCount(compressed);
|
|
203
183
|
const forceLine = nonEmptyCount < forceFirstCount;
|
|
204
|
-
const wouldOverflow = selected.length > 0 && (usedTokens + tokens) > maxTokens;
|
|
205
184
|
const wouldExceedLines = nonEmptyCount >= maxLines;
|
|
206
185
|
|
|
207
|
-
if (!forceLine && (
|
|
186
|
+
if (wouldExceedLines || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
|
|
208
187
|
break;
|
|
209
188
|
}
|
|
210
|
-
|
|
189
|
+
|
|
190
|
+
const selectedLine = forceLine && (usedTokens + tokens) > hardTokenCap
|
|
191
|
+
? truncateToTokenBudget(compressed, hardTokenCap - usedTokens)
|
|
192
|
+
: compressed;
|
|
193
|
+
const selectedTokens = estimateTokenCount(selectedLine);
|
|
194
|
+
|
|
195
|
+
if (!selectedLine) {
|
|
211
196
|
break;
|
|
212
197
|
}
|
|
213
198
|
|
|
214
|
-
selected.push(
|
|
199
|
+
selected.push(selectedLine);
|
|
215
200
|
seen.add(dedupeKey);
|
|
216
|
-
usedTokens +=
|
|
201
|
+
usedTokens += selectedTokens;
|
|
217
202
|
nonEmptyCount += 1;
|
|
218
203
|
}
|
|
219
204
|
|
|
@@ -236,6 +221,13 @@ function findMissingAnchors(text, anchorLines) {
|
|
|
236
221
|
});
|
|
237
222
|
}
|
|
238
223
|
|
|
224
|
+
function truncateToTokenBudget(line, maxTokens) {
|
|
225
|
+
if (maxTokens <= 0) return '';
|
|
226
|
+
const maxChars = Math.max(1, (maxTokens * 4) - 4);
|
|
227
|
+
const value = String(line ?? '');
|
|
228
|
+
return value.length <= maxChars ? value : `${value.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
|
|
229
|
+
}
|
|
230
|
+
|
|
239
231
|
export async function readCompactHistory(projectRoot, options = {}) {
|
|
240
232
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
241
233
|
const raw = await readJsonIfExists(runtimePaths.compactHistoryPath);
|
|
@@ -59,7 +59,7 @@ export async function ensureGitignore(projectRoot) {
|
|
|
59
59
|
.split('\n')
|
|
60
60
|
.map((l) => l.trim())
|
|
61
61
|
.filter((trimmed) => trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('!'));
|
|
62
|
-
const missing = UKIT_ENTRIES.filter((e) => !activeLines.some((l) => l
|
|
62
|
+
const missing = UKIT_ENTRIES.filter((e) => !activeLines.some((l) => l === e));
|
|
63
63
|
if (missing.length === 0) {
|
|
64
64
|
return false;
|
|
65
65
|
}
|
package/src/core/fileOps.js
CHANGED
|
@@ -14,8 +14,11 @@ export async function readJsonIfExists(filePath) {
|
|
|
14
14
|
try {
|
|
15
15
|
const raw = await fs.readFile(filePath, 'utf8');
|
|
16
16
|
return JSON.parse(raw);
|
|
17
|
-
} catch {
|
|
18
|
-
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error?.code === 'ENOENT') {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -23,6 +26,42 @@ export function escapeRegExp(str) {
|
|
|
23
26
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
async function isDirEmpty(dirPath) {
|
|
30
|
+
try {
|
|
31
|
+
const entries = await fs.readdir(dirPath);
|
|
32
|
+
return entries.length === 0;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function cleanupEmptyParents(targetPath, projectRoot) {
|
|
39
|
+
let currentDir = path.dirname(targetPath);
|
|
40
|
+
const stopDir = path.resolve(projectRoot);
|
|
41
|
+
|
|
42
|
+
while (currentDir !== stopDir) {
|
|
43
|
+
const relative = path.relative(stopDir, currentDir);
|
|
44
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let stat;
|
|
49
|
+
try {
|
|
50
|
+
stat = await fs.lstat(currentDir);
|
|
51
|
+
} catch {
|
|
52
|
+
currentDir = path.dirname(currentDir);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!stat.isDirectory() || !(await isDirEmpty(currentDir))) {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await fs.rmdir(currentDir);
|
|
61
|
+
currentDir = path.dirname(currentDir);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
26
65
|
export function resolveProjectRelativePath(projectRoot, relPath) {
|
|
27
66
|
if (typeof relPath !== 'string') {
|
|
28
67
|
return null;
|
|
@@ -141,8 +141,24 @@ function archiveSessions(projectMemory, config) {
|
|
|
141
141
|
return archived;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
function normalizeConfig(config = {}) {
|
|
145
145
|
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
146
|
+
return {
|
|
147
|
+
...mergedConfig,
|
|
148
|
+
archiveAfterDays: Number.isFinite(mergedConfig.archiveAfterDays) && mergedConfig.archiveAfterDays > 0
|
|
149
|
+
? mergedConfig.archiveAfterDays
|
|
150
|
+
: DEFAULT_CONFIG.archiveAfterDays,
|
|
151
|
+
maxSessionsKept: Number.isInteger(mergedConfig.maxSessionsKept) && mergedConfig.maxSessionsKept > 0
|
|
152
|
+
? mergedConfig.maxSessionsKept
|
|
153
|
+
: DEFAULT_CONFIG.maxSessionsKept,
|
|
154
|
+
redactPatterns: Array.isArray(mergedConfig.redactPatterns)
|
|
155
|
+
? mergedConfig.redactPatterns
|
|
156
|
+
: DEFAULT_CONFIG.redactPatterns,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function runHygiene(projectMemory, config = {}) {
|
|
161
|
+
const mergedConfig = normalizeConfig(config);
|
|
146
162
|
const nextMemory = redactValue(clone(projectMemory), mergedConfig.redactPatterns);
|
|
147
163
|
|
|
148
164
|
nextMemory.conventions = uniqueStrings(nextMemory.conventions);
|
package/src/core/memory/store.js
CHANGED
|
@@ -85,28 +85,6 @@ function createSearchableText(type, content) {
|
|
|
85
85
|
].filter(Boolean).join('\n');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function computeRelevance(searchableText, query) {
|
|
89
|
-
if (!query) {
|
|
90
|
-
return 1;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const haystack = searchableText.toLowerCase();
|
|
94
|
-
const needle = query.toLowerCase();
|
|
95
|
-
if (!haystack.includes(needle)) {
|
|
96
|
-
return 0;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const parts = needle.split(/\s+/).filter(Boolean);
|
|
100
|
-
let score = 1;
|
|
101
|
-
for (const part of parts) {
|
|
102
|
-
if (haystack.includes(part)) {
|
|
103
|
-
score += 1;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return score;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
88
|
export async function exportMemory(projectRoot) {
|
|
111
89
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
112
90
|
const user = (await readJsonIfExists(runtimePaths.userMemoryPath)) ?? defaultUserMemory();
|
|
@@ -150,18 +128,6 @@ export async function listMemoryItems(projectRoot) {
|
|
|
150
128
|
];
|
|
151
129
|
}
|
|
152
130
|
|
|
153
|
-
export async function searchMemoryItems(projectRoot, query, { limit = 10 } = {}) {
|
|
154
|
-
const items = await listMemoryItems(projectRoot);
|
|
155
|
-
return items
|
|
156
|
-
.map((item) => ({
|
|
157
|
-
...item,
|
|
158
|
-
relevanceScore: computeRelevance(`${item.summary}\n${item.searchableText}`, query),
|
|
159
|
-
}))
|
|
160
|
-
.filter((item) => item.relevanceScore > 0)
|
|
161
|
-
.sort((left, right) => right.relevanceScore - left.relevanceScore)
|
|
162
|
-
.slice(0, limit);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
131
|
export async function forgetMemoryItem(projectRoot, memoryId) {
|
|
166
132
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
167
133
|
if (memoryId === 'user:user' || memoryId === 'user') {
|
|
@@ -169,11 +135,18 @@ export async function forgetMemoryItem(projectRoot, memoryId) {
|
|
|
169
135
|
return { removed: true, type: 'user', path: runtimePaths.userMemoryPath };
|
|
170
136
|
}
|
|
171
137
|
|
|
172
|
-
const
|
|
173
|
-
|
|
138
|
+
const id = String(memoryId);
|
|
139
|
+
const separatorIndex = id.indexOf(':');
|
|
140
|
+
if (separatorIndex <= 0 || separatorIndex === id.length - 1) {
|
|
174
141
|
return { removed: false, type: null, path: null };
|
|
175
142
|
}
|
|
176
143
|
|
|
144
|
+
const type = id.slice(0, separatorIndex);
|
|
145
|
+
const rawId = id.slice(separatorIndex + 1);
|
|
146
|
+
if (rawId.includes('/') || rawId.includes('\\') || rawId === '..') {
|
|
147
|
+
return { removed: false, type, path: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
177
150
|
const baseDir = type === 'project'
|
|
178
151
|
? runtimePaths.projectsDir
|
|
179
152
|
: type === 'session'
|
|
@@ -184,6 +157,11 @@ export async function forgetMemoryItem(projectRoot, memoryId) {
|
|
|
184
157
|
}
|
|
185
158
|
|
|
186
159
|
const targetPath = path.join(baseDir, `${rawId}.json`);
|
|
160
|
+
const relativeTarget = path.relative(path.resolve(baseDir), path.resolve(targetPath));
|
|
161
|
+
if (!relativeTarget || relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
|
|
162
|
+
return { removed: false, type, path: null };
|
|
163
|
+
}
|
|
164
|
+
|
|
187
165
|
try {
|
|
188
166
|
await fs.unlink(targetPath);
|
|
189
167
|
return { removed: true, type, path: targetPath };
|
package/src/core/metadata.js
CHANGED
|
@@ -111,17 +111,17 @@ export async function removeTrackedPathsFromMetadata({
|
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
const targetPaths = [...new Set(removeRelativePaths
|
|
115
|
+
.map((relPath) => (typeof relPath === 'string' ? relPath.trim().replace(/\\/g, '/') : ''))
|
|
116
|
+
.filter(Boolean))];
|
|
117
117
|
|
|
118
118
|
const filteredFiles = metadata.files.filter((entry) => {
|
|
119
119
|
const p = typeof entry === 'string' ? entry : entry?.p;
|
|
120
120
|
if (!p) {
|
|
121
121
|
return true;
|
|
122
122
|
}
|
|
123
|
-
const normalized = p.replace(/\\/g, '/');
|
|
124
|
-
return !
|
|
123
|
+
const normalized = p.trim().replace(/\\/g, '/');
|
|
124
|
+
return !targetPaths.some((targetPath) => isSameOrDescendantPath(normalized, targetPath));
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
if (filteredFiles.length === metadata.files.length) {
|
package/src/core/output/index.js
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
compressLine,
|
|
9
9
|
compressMarkdownLines,
|
|
10
10
|
estimateTokenCount,
|
|
11
|
+
normalizeLineForDedupe,
|
|
11
12
|
readPromptCacheEntry,
|
|
13
|
+
uniqueLines,
|
|
12
14
|
writePromptCacheEntry,
|
|
13
15
|
} from '../token/index.js';
|
|
14
16
|
|
|
@@ -191,10 +193,6 @@ function splitLines(value) {
|
|
|
191
193
|
.map((line) => line.replace(/\s+$/g, ''));
|
|
192
194
|
}
|
|
193
195
|
|
|
194
|
-
function normalizeLineForDedupe(line) {
|
|
195
|
-
return String(line ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
196
|
-
}
|
|
197
|
-
|
|
198
196
|
function matchesAnyPattern(line, patterns = []) {
|
|
199
197
|
return patterns.some((pattern) => pattern.test(line));
|
|
200
198
|
}
|
|
@@ -284,7 +282,8 @@ function buildRecoveryFileName({
|
|
|
284
282
|
profile: String(profile ?? 'generic').trim(),
|
|
285
283
|
exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null,
|
|
286
284
|
}).split(':').at(-1)?.replace(/[^a-z0-9_-]/gi, '-').slice(0, 16) || 'recovery';
|
|
287
|
-
|
|
285
|
+
const uniqueSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
286
|
+
return `${slug}-${uniqueSuffix}-${fingerprint}.log`;
|
|
288
287
|
}
|
|
289
288
|
|
|
290
289
|
function buildRawOutputText({ command = '', stdout = '', stderr = '', exitCode = null } = {}) {
|
|
@@ -425,6 +424,7 @@ function isTailSummaryLine(line, profile = null) {
|
|
|
425
424
|
}
|
|
426
425
|
|
|
427
426
|
function buildCompactedSummaryLines(lines, { maxTokens = 180, maxLines = 10, forceFirstCount = 1 } = {}) {
|
|
427
|
+
const hardTokenCap = Math.max(maxTokens, Math.ceil(maxTokens * 2));
|
|
428
428
|
const sourceLines = Array.isArray(lines) ? lines : [];
|
|
429
429
|
const selected = [];
|
|
430
430
|
const seen = new Set();
|
|
@@ -440,19 +440,24 @@ function buildCompactedSummaryLines(lines, { maxTokens = 180, maxLines = 10, for
|
|
|
440
440
|
|
|
441
441
|
const tokens = estimateTokenCount(compressed);
|
|
442
442
|
const forceLine = nonEmptyCount < forceFirstCount;
|
|
443
|
-
const wouldOverflow = selected.length > 0 && (usedTokens + tokens) > maxTokens;
|
|
444
443
|
const wouldExceedLines = nonEmptyCount >= maxLines;
|
|
445
444
|
|
|
446
|
-
if (!forceLine && (
|
|
445
|
+
if (wouldExceedLines || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
|
|
447
446
|
break;
|
|
448
447
|
}
|
|
449
|
-
|
|
448
|
+
|
|
449
|
+
const selectedLine = forceLine && (usedTokens + tokens) > hardTokenCap
|
|
450
|
+
? truncateToTokenBudget(compressed, hardTokenCap - usedTokens)
|
|
451
|
+
: compressed;
|
|
452
|
+
const selectedTokens = estimateTokenCount(selectedLine);
|
|
453
|
+
|
|
454
|
+
if (!selectedLine) {
|
|
450
455
|
break;
|
|
451
456
|
}
|
|
452
457
|
|
|
453
|
-
selected.push(
|
|
458
|
+
selected.push(selectedLine);
|
|
454
459
|
seen.add(dedupeKey);
|
|
455
|
-
usedTokens +=
|
|
460
|
+
usedTokens += selectedTokens;
|
|
456
461
|
nonEmptyCount += 1;
|
|
457
462
|
}
|
|
458
463
|
|
|
@@ -471,16 +476,11 @@ function findMissingAnchors(summary, anchorLines) {
|
|
|
471
476
|
});
|
|
472
477
|
}
|
|
473
478
|
|
|
474
|
-
function
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
if (!normalized || seen.has(normalized)) continue;
|
|
480
|
-
seen.add(normalized);
|
|
481
|
-
unique.push(String(line ?? '').trim());
|
|
482
|
-
}
|
|
483
|
-
return unique;
|
|
479
|
+
function truncateToTokenBudget(line, maxTokens) {
|
|
480
|
+
if (maxTokens <= 0) return '';
|
|
481
|
+
const maxChars = Math.max(1, (maxTokens * 4) - 4);
|
|
482
|
+
const value = String(line ?? '');
|
|
483
|
+
return value.length <= maxChars ? value : `${value.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
|
|
484
484
|
}
|
|
485
485
|
|
|
486
486
|
function looksLikeSearchPath(filePath) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathExists } from './fileOps.js';
|
|
3
|
+
|
|
4
|
+
export const PACKAGE_MANAGER_LOCKFILES = [
|
|
5
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
6
|
+
['yarn.lock', 'yarn'],
|
|
7
|
+
['bun.lockb', 'bun'],
|
|
8
|
+
['bun.lock', 'bun'],
|
|
9
|
+
['package-lock.json', 'npm'],
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function getDeclaredPackageManager(pkg = null) {
|
|
13
|
+
const declared = String(pkg?.packageManager ?? '').toLowerCase();
|
|
14
|
+
if (declared.startsWith('pnpm')) return 'pnpm';
|
|
15
|
+
if (declared.startsWith('yarn')) return 'yarn';
|
|
16
|
+
if (declared.startsWith('bun')) return 'bun';
|
|
17
|
+
if (declared.startsWith('npm')) return 'npm';
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function detectPackageManager(projectRoot, packageJson = null) {
|
|
22
|
+
const declaredPackageManager = getDeclaredPackageManager(packageJson);
|
|
23
|
+
if (declaredPackageManager) return declaredPackageManager;
|
|
24
|
+
|
|
25
|
+
for (const [lockfile, packageManager] of PACKAGE_MANAGER_LOCKFILES) {
|
|
26
|
+
if (await pathExists(path.join(projectRoot, lockfile))) {
|
|
27
|
+
return packageManager;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return 'npm';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function detectPackageManagerFromFingerprint({ fingerprintEntries = [], pkg = null } = {}) {
|
|
35
|
+
const declaredPackageManager = getDeclaredPackageManager(pkg);
|
|
36
|
+
if (declaredPackageManager) return declaredPackageManager;
|
|
37
|
+
|
|
38
|
+
const existingFiles = new Set(
|
|
39
|
+
fingerprintEntries
|
|
40
|
+
.filter((entry) => entry && entry.mtimeMs !== null)
|
|
41
|
+
.map((entry) => entry.filePath),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
for (const [lockfile, packageManager] of PACKAGE_MANAGER_LOCKFILES) {
|
|
45
|
+
if (existingFiles.has(lockfile)) {
|
|
46
|
+
return packageManager;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return 'npm';
|
|
51
|
+
}
|
|
@@ -30,6 +30,22 @@ function normalize(text) {
|
|
|
30
30
|
return String(text ?? '').toLowerCase();
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function escapeRegExp(value) {
|
|
34
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function matchesKeyword(text, keyword) {
|
|
38
|
+
if (keyword.startsWith('.')) {
|
|
39
|
+
return new RegExp(`(?:^|[\\s/\\\\])[^\\s/\\\\]+${escapeRegExp(keyword)}(?:$|[\\s)\\],.;:])`).test(text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (keyword.includes(' ')) {
|
|
43
|
+
return new RegExp(`\\b${keyword.split(/\\s+/).map(escapeRegExp).join('\\\\s+')}\\b`).test(text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return new RegExp(`\\b${escapeRegExp(keyword)}\\b`).test(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
function countFileHints(text) {
|
|
34
50
|
const explicitCount = Number.parseInt(normalize(text).match(/\b(\d+)\s+files?\b/)?.[1] ?? '0', 10);
|
|
35
51
|
const pathMatches = normalize(text).match(/\b[\w./-]+\.(?:js|ts|tsx|jsx|json|md|yaml|yml)\b/g) ?? [];
|
|
@@ -41,8 +57,8 @@ export function detectComplexity(message, context = '') {
|
|
|
41
57
|
const wordCount = combined.split(/\s+/).filter(Boolean).length;
|
|
42
58
|
const fileHints = countFileHints(combined);
|
|
43
59
|
const hasRetryPattern = /\b(retry|failed|failure|attempt)\b/.test(combined);
|
|
44
|
-
const highSignals = HIGH_COMPLEXITY_KEYWORDS.filter((keyword) => combined
|
|
45
|
-
const lowSignals = LOW_COMPLEXITY_KEYWORDS.filter((keyword) => combined
|
|
60
|
+
const highSignals = HIGH_COMPLEXITY_KEYWORDS.filter((keyword) => matchesKeyword(combined, keyword)).length;
|
|
61
|
+
const lowSignals = LOW_COMPLEXITY_KEYWORDS.filter((keyword) => matchesKeyword(combined, keyword)).length;
|
|
46
62
|
|
|
47
63
|
if (highSignals > 0 || fileHints > 5 || (hasRetryPattern && /\b(debug|error|crash|stack trace)\b/.test(combined))) {
|
|
48
64
|
return 'high';
|
|
@@ -58,19 +74,19 @@ export function detectComplexity(message, context = '') {
|
|
|
58
74
|
function detectTaskType(message, context = '', complexity = 'medium') {
|
|
59
75
|
const combined = normalize(`${message}\n${context}`);
|
|
60
76
|
|
|
61
|
-
if (DEBUG_KEYWORDS.some((keyword) => combined
|
|
77
|
+
if (DEBUG_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword)) && complexity === 'high') {
|
|
62
78
|
return 'debug_hard';
|
|
63
79
|
}
|
|
64
80
|
|
|
65
|
-
if (HIGH_COMPLEXITY_KEYWORDS.some((keyword) => combined
|
|
81
|
+
if (HIGH_COMPLEXITY_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword))) {
|
|
66
82
|
return 'reasoning';
|
|
67
83
|
}
|
|
68
84
|
|
|
69
|
-
if (REVIEW_KEYWORDS.some((keyword) => combined
|
|
85
|
+
if (REVIEW_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword))) {
|
|
70
86
|
return 'review';
|
|
71
87
|
}
|
|
72
88
|
|
|
73
|
-
if (CODING_HINTS.some((keyword) => combined
|
|
89
|
+
if (CODING_HINTS.some((keyword) => matchesKeyword(combined, keyword))) {
|
|
74
90
|
return 'coding';
|
|
75
91
|
}
|
|
76
92
|
|