leerness 1.36.22 → 1.36.23
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 +13 -0
- package/README.ko.md +1 -1
- package/README.md +4 -4
- package/bin/leerness.js +80 -17
- package/lib/catalogs.js +17 -1
- package/lib/search-core.js +85 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.23 — 2026-07-14 — memory search: 한국어↔영어 동의어(핸드오프 0→38) + BM25 랭킹("앞 N건"→"상위 N건") + JSON 정합
|
|
4
|
+
|
|
5
|
+
ui-ux-pro-max 의 BM25 검색엔진을 검토해 **정렬 레이어로만** 이식. 원안(BM25 를 리콜 엔진으로)은 부적합 — **토큰화 리콜은 언어별 형태론 가정을 끌어들여** 기존 히트를 잃는다. 실측: `memory search "핸드오프"` 는 substring 이라 "핸드오프**를** 자동화…"를 찾지만, 공백 토큰화 BM25 로 리콜을 대체하면 doc 토큰이 `['핸드오프를',…]` 이라 쿼리와 불일치해 **지금 찾히는 문서가 0건**이 된다(교착어 조사가 대표 사례이고, 굴절·합성어 언어에서도 같은 계열의 손실). 형태소 분석기는 의존성 0 위반이라 불가 → substring 은 **언어 중립적이고 상위집합 안전**하므로 리콜로 유지한다.
|
|
6
|
+
|
|
7
|
+
- **`lib/search-core.js` 신설** (순수함수 · 0-deps · 산술만): `tokenizeForRank`(한글은 조사 절단 근사로 prefix 변형 방출 — `핸드오프를` 이 `핸드오프` 쿼리에 스코어를 받음) · `expandQuery`(동의어 맵 주입 → 순수) · `scoreHits`(BM25 k1=1.5/b=0.75) · `suggestTerms`.
|
|
8
|
+
- ★ **설계 불변식**: BM25 는 정렬만, 리콜은 substring 유지 → **토크나이저가 틀려도 히트가 0이 되지 않는다**(정렬 순서만 나빠질 뿐). 안전판.
|
|
9
|
+
- **동의어(`MEMORY_SYNONYMS`, catalogs 단일출처)** — 같은 개념을 다른 언어로 물으면 결과가 갈리던 비대칭 해소: `핸드오프` **0 → 38** hits, `교훈` 3 → 18, `결정` 4 → 18, `작업` 11 → 27, `계획` 5 → 19. 메모리 내용은 언어가 섞이기 마련이라(코드·커밋은 영어, 결정·교훈은 모국어) 한쪽 표기만 아는 사용자/에이전트가 존재하는 기록을 못 찾았다. 양방향(어느 쪽으로 물어도 동일). `--no-synonyms` 로 종전 동작(실측 재현 확인).
|
|
10
|
+
- **BM25 랭킹** — 종전엔 `--limit`(기본 5)이 파일당 **"앞 5건"** 임의 절단이었다(최고 5건이 아니라). 이제 히트를 전부 모아 스코어 → 정렬 → limit ⇒ **"상위 N건"**. 파일도 최고점수 순. `--include-code`(21k줄 소스)에서 특히 자의적이던 절단 해소.
|
|
11
|
+
- **`--json` 정합** — 종전 `total: 16 / results.length: 15` 처럼 어긋나던 것을 `total`(전체 히트) + `shown` + `truncated` 로 자기정합화. 모든 result 에 `score` 추가(메모리/코드 경로 형태 통일 — 종전엔 코드 경로만 score 없이 섞였다).
|
|
12
|
+
- **0건 근접어 제안** — 실제 색인 어휘에서만 추출(지어내지 않음): `혹시 이건가요: …`.
|
|
13
|
+
- **검증**: selftest 293/293 — 판정단이 채택 조건으로 건 가드 3종을 그대로 박음: (a) **한국어 조사 회귀 금지**(`핸드오프` 쿼리가 `핸드오프를…` 에 반드시 스코어 + substring 안전판 확인), (b) 동의어 순증(원본 항상 포함·양방향·모르는 말은 원본 그대로), (c) BM25 단조성(tf↑→score↑, 희귀어 idf↑). **정밀도 재검**(리콜 확대가 잠복 FP 를 깨우는지): 6개 동의어쌍 전수 — **리콜 회귀 0 · 추가 히트 중 동의어 무관 FP 0**. 게이트 e2e, 게시본 클린룸.
|
|
14
|
+
- 이연: skill match(jaccard)의 search-core 통합은 scope 경계로 제외 — 후속 후보.
|
|
15
|
+
|
|
3
16
|
## 1.36.22 — 2026-07-14 — SessionStart 컨텍스트 주입(compaction 후 컨텍스트 유실 차단) + README 를 소개·사용법·효과로 재구성
|
|
4
17
|
|
|
5
18
|
**① SessionStart 컨텍스트 주입** (obra/superpowers 구조 검토 → 배선). 실측 결함: compaction/resume 후 에이전트가 "세션 시작 handoff" 의례를 건너뛰어 컨텍스트를 잃는다. superpowers 는 hook matcher 를 `startup|clear|compact` 로 걸어 하네스가 컨텍스트를 강제 적재한다(에이전트 자발성에 안 기댐) — leerness 는 hook 플럼빙과 compact 신호를 **이미 둘 다 갖고 있었고 배선만 없었다**.
|
package/README.ko.md
CHANGED
|
@@ -164,7 +164,7 @@ leerness mcp serve # JSON-RPC over stdio, 85 도구
|
|
|
164
164
|
- **에이전트 중립** — Claude / Codex / Cursor / Aider / Goose 등 어디에나 적용.
|
|
165
165
|
- **거짓 완료 방지** — 완료 주장에 evidence 를 요구하는 anti-lazy 흐름.
|
|
166
166
|
- **통합 게이트** — 보안·인코딩·드리프트·검증을 `gate` 하나로.
|
|
167
|
-
-
|
|
167
|
+
- **Windows/인코딩 1급 시민** — CP949/BOM 가드, 터미널 인코딩 자동 회복. 출력 언어는 기본 ko · `--language en` 으로 영어.
|
|
168
168
|
- **자기기술(self-describing)** — `about`/`commands`/`capabilities`/`doctor` 로 도구가 스스로 설명.
|
|
169
169
|
|
|
170
170
|
---
|
package/README.md
CHANGED
|
@@ -121,7 +121,7 @@ MIT
|
|
|
121
121
|
<!-- leerness:project-readme:start -->
|
|
122
122
|
## Leerness Project Harness
|
|
123
123
|
|
|
124
|
-
이 프로젝트는 Leerness v1.36.
|
|
124
|
+
이 프로젝트는 Leerness v1.36.23 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
125
125
|
|
|
126
126
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
127
127
|
|
|
@@ -175,7 +175,7 @@ leerness memory restore decision <date|title>
|
|
|
175
175
|
|
|
176
176
|
### MCP server (외부 AI 통합)
|
|
177
177
|
|
|
178
|
-
Leerness v1.36.
|
|
178
|
+
Leerness v1.36.23는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
|
|
179
179
|
|
|
180
180
|
```jsonc
|
|
181
181
|
// 카테고리별
|
|
@@ -196,7 +196,7 @@ Leerness v1.36.22는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
|
|
|
196
196
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
197
197
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
|
|
198
198
|
|
|
199
|
-
현재 누적: **70 라운드 (1.9.40 → 1.36.
|
|
199
|
+
현재 누적: **70 라운드 (1.9.40 → 1.36.23)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
|
|
200
200
|
|
|
201
201
|
### 성능 가이드 (1.9.140 측정)
|
|
202
202
|
|
|
@@ -234,6 +234,6 @@ leerness release pack --close --auto-main-push
|
|
|
234
234
|
- `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
235
235
|
- `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
236
236
|
|
|
237
|
-
Last synced by Leerness v1.36.
|
|
237
|
+
Last synced by Leerness v1.36.23: 2026-07-15
|
|
238
238
|
<!-- leerness:project-readme:end -->
|
|
239
239
|
|
package/bin/leerness.js
CHANGED
|
@@ -30,10 +30,11 @@ const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, _mergeLines, _merg
|
|
|
30
30
|
// 1.9.304 (UR-0025): 순수 분석/검증 함수 모듈 분리.
|
|
31
31
|
const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck } = require('../lib/analyzers');
|
|
32
32
|
// 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
|
|
33
|
-
const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
|
|
33
|
+
const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, MEMORY_SYNONYMS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
|
|
34
|
+
const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
|
|
34
35
|
const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
|
|
35
36
|
|
|
36
|
-
const VERSION = '1.36.
|
|
37
|
+
const VERSION = '1.36.23';
|
|
37
38
|
|
|
38
39
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
39
40
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
@@ -253,7 +254,7 @@ function detectLanguageValue(root, value = 'auto') {
|
|
|
253
254
|
// ③ en 폴백
|
|
254
255
|
return 'en';
|
|
255
256
|
}
|
|
256
|
-
// 1.20.2 (UR-0010 CLI 영어화 Phase 1): UI 출력 언어 해석 —
|
|
257
|
+
// 1.20.2 (UR-0010 CLI 영어화 Phase 1): UI 출력 언어 해석 — 기본 ko(기존 사용자 기본값), 영어는 --language en / LEERNESS_LANG=en.
|
|
257
258
|
// 우선순위: --language 플래그 > LEERNESS_LANG env > .harness/manifest.json 의 language(init 선택) > 'ko'.
|
|
258
259
|
// (system locale 은 의도적으로 미사용 — 영어 OS 한국 사용자 놀람 방지. 영어는 명시 opt-in.)
|
|
259
260
|
function _uiLang(root) {
|
|
@@ -2982,6 +2983,37 @@ function _selfTestCases() {
|
|
|
2982
2983
|
const encOk = s.includes("(result.applied || []).some(a => a.action === 'failed')) process.exitCode = 1"); // encoding: 실패 exit1
|
|
2983
2984
|
return reuseOk && releaseOk && encOk;
|
|
2984
2985
|
} },
|
|
2986
|
+
{ name: 'memory search 랭킹/동의어 (1.36.23): 한국어 조사 회귀 0 + 동의어 리콜 순증 + BM25 단조성 — 순수 행위검사', run: () => {
|
|
2987
|
+
const sc = require('../lib/search-core');
|
|
2988
|
+
const { MEMORY_SYNONYMS } = require('../lib/catalogs');
|
|
2989
|
+
if (typeof sc.scoreHits !== 'function' || typeof sc.expandQuery !== 'function') return false;
|
|
2990
|
+
// (a) ★ 한국어 조사 회귀 금지 — 이 라운드의 최대 리스크. 토큰화 리콜로 바꿨다면 여기서 죽는다.
|
|
2991
|
+
// '핸드오프' 쿼리가 '핸드오프를…' 문서에 반드시 스코어를 준다(랭킹 토크나이저의 prefix 변형).
|
|
2992
|
+
const ko = sc.scoreHits('핸드오프', [{ text: '핸드오프를 자동화하기로 결정했다' }]);
|
|
2993
|
+
const koOk = ko.length === 1 && ko[0].score > 0;
|
|
2994
|
+
// 리콜 자체는 caller 의 substring 이 담당 — 안전판 확인(토크나이저가 조사를 못 떼도 substring 은 찾는다)
|
|
2995
|
+
const substringStillFinds = new RegExp('핸드오프', 'i').test('핸드오프를 자동화하기로 결정했다');
|
|
2996
|
+
// (b) 동의어 확장은 "순증"만 — 원본이 항상 포함되고(리콜 감소 0), 양방향
|
|
2997
|
+
const ex1 = sc.expandQuery('핸드오프', MEMORY_SYNONYMS);
|
|
2998
|
+
const ex2 = sc.expandQuery('handoff', MEMORY_SYNONYMS);
|
|
2999
|
+
const superset = ex1[0] === '핸드오프' && ex1.includes('handoff') && ex2[0] === 'handoff' && ex2.includes('핸드오프');
|
|
3000
|
+
const unknownKeeps = JSON.stringify(sc.expandQuery('zzz없는말', MEMORY_SYNONYMS)) === JSON.stringify(['zzz없는말']); // 모르는 말은 원본 그대로
|
|
3001
|
+
// (c) BM25 단조성: tf↑ → score↑ / 희귀어(idf↑)가 흔한 어휘보다 높은 점수
|
|
3002
|
+
const tfLow = sc.scoreHits('alpha', [{ text: 'alpha beta gamma delta' }])[0].score;
|
|
3003
|
+
const tfHigh = sc.scoreHits('alpha', [{ text: 'alpha alpha alpha beta gamma delta' }])[0].score;
|
|
3004
|
+
const monotonic = tfHigh > tfLow;
|
|
3005
|
+
const corpus = [{ text: 'rare unique term' }, { text: 'common word here' }, { text: 'common word there' }, { text: 'common word everywhere' }];
|
|
3006
|
+
const rareScore = sc.scoreHits('rare', corpus)[0].score;
|
|
3007
|
+
const commonScore = sc.scoreHits('common', corpus)[1].score;
|
|
3008
|
+
const idfOk = rareScore > commonScore;
|
|
3009
|
+
// (d) 0건 제안은 실제 어휘에서만(지어내지 않음)
|
|
3010
|
+
const sug = sc.suggestTerms('핸드', new Set(['핸드오프', '무관어']), { limit: 3 });
|
|
3011
|
+
const sugOk = sug.includes('핸드오프') && !sug.includes('무관어');
|
|
3012
|
+
// 배선 가드: 리콜은 substring(OR), 랭킹은 BM25, limit 은 정렬 "후"
|
|
3013
|
+
const s = read(__filename);
|
|
3014
|
+
const wired = s.includes('const _isHit = line => res.some(r => r.test(line));') && s.includes('_scoreHits(query, raw.map') && s.includes('.slice(0, _limit)');
|
|
3015
|
+
return koOk && substringStillFinds && superset && unknownKeeps && monotonic && idfOk && sugOk && wired;
|
|
3016
|
+
} },
|
|
2985
3017
|
{ name: 'SessionStart 컨텍스트 주입 (1.36.22, obra/superpowers 구조 검토): hook 전용 표면 + 쓰기0 가드 + matcher 에 compact 포함 + opt-out — 소스가드', run: () => {
|
|
2986
3018
|
const s = read(__filename);
|
|
2987
3019
|
const surface = typeof hookSessionStartCmd === 'function' && s.includes("cmd === 'hook' && args[1] === 'session-start'");
|
|
@@ -4192,7 +4224,7 @@ function _selfTestCases() {
|
|
|
4192
4224
|
{ name: 'CLI 영어화 Phase 1 (1.20.2, UR-0010): _uiLang 해석(flag>env>manifest>ko) + 첫화면 _t 적용 (행위+소스)', run: () => {
|
|
4193
4225
|
const save = process.argv; const saveEnv = process.env.LEERNESS_LANG;
|
|
4194
4226
|
try {
|
|
4195
|
-
// 기본 ko (
|
|
4227
|
+
// 기본 ko (기존 사용자 기본값 보존 — 영어는 --language en / LEERNESS_LANG=en)
|
|
4196
4228
|
process.argv = ['node', 'h', 'handoff']; delete process.env.LEERNESS_LANG;
|
|
4197
4229
|
if (_uiLang('/no/such/dir') !== 'ko') return false;
|
|
4198
4230
|
// --language en 플래그 → en
|
|
@@ -9041,17 +9073,32 @@ function memorySearch(root, query) {
|
|
|
9041
9073
|
if (!query) { failJson(jsonMode, 'query_required', 'query required (e.g., memory search "키워드")'); return; }
|
|
9042
9074
|
// 1.13.1 (15th 블라인드 리뷰 P1, Sonnet): lessons.md + rules.md 누락 수정 — memory search 가 5종 메모리 표면을 표방하나 lesson/rule 을 검색 못 해(lesson add/rule add 로 저장한 교훈·룰이 'no matches') 모순감지 핵심 용도가 훼손됐음.
|
|
9043
9075
|
const files = ['.harness/decisions.md','.harness/lessons.md','.harness/rules.md','.harness/task-log.md','.harness/session-handoff.md','.harness/progress-tracker.md','.harness/plan.md','.harness/review-evidence.md','.harness/architecture.md'];
|
|
9044
|
-
|
|
9076
|
+
// 1.36.23: (1) 동의어 확장으로 리콜 "순증" — 실측 `핸드오프` 0 hits / `handoff` 37 hits 비대칭 해소. 기존 regex 에 OR 로 더하므로
|
|
9077
|
+
// 현 동작의 상위집합(히트 감소 0). --no-synonyms 로 종전 동작. (2) 히트를 BM25 로 정렬한 뒤 --limit 적용 — 종전엔 파일당
|
|
9078
|
+
// "앞 N건" 임의 절단이었다(최고 N건이 아니라). ★ 리콜은 substring 유지: 한국어 조사 때문에 토큰화 리콜은 지금 찾히는 문서를
|
|
9079
|
+
// 0건으로 만든다(lib/search-core.js 주석 참조) — BM25 는 정렬 전용이라 토크나이저가 틀려도 히트가 사라지지 않는다.
|
|
9080
|
+
const useSyn = !has('--no-synonyms');
|
|
9081
|
+
const terms = useSyn ? _expandQuery(query, MEMORY_SYNONYMS) : [query];
|
|
9082
|
+
const res = terms.map(t => new RegExp(String(t).replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'), 'i'));
|
|
9083
|
+
const _isHit = line => res.some(r => r.test(line)); // 리콜 = substring OR(원본 + 동의어) — 한국어 조사 안전판
|
|
9084
|
+
const _limit = _parseLimit(arg('--limit','5'),5);
|
|
9045
9085
|
let total = 0;
|
|
9086
|
+
const _blocks = []; // 파일별 결과 — best score 순으로 출력(관련 높은 파일 먼저)
|
|
9046
9087
|
for (const f of files) {
|
|
9047
9088
|
const p = path.join(root, f); if (!exists(p)) continue;
|
|
9048
9089
|
const lines = read(p).split('\n');
|
|
9049
|
-
const
|
|
9050
|
-
if (
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
}
|
|
9090
|
+
const raw = lines.map((line, i) => ({ line, i })).filter(x => _isHit(x.line));
|
|
9091
|
+
if (!raw.length) continue;
|
|
9092
|
+
const scored = _scoreHits(query, raw.map(x => ({ line: x.i + 1, text: x.line.trim() })))
|
|
9093
|
+
.sort((a, b) => (b.score - a.score) || (a.line - b.line)); // 동점이면 원래 순서(결정적)
|
|
9094
|
+
const top = scored.slice(0, _limit);
|
|
9095
|
+
_blocks.push({ file: f, top, best: top.length ? top[0].score : 0 });
|
|
9096
|
+
total += raw.length;
|
|
9097
|
+
}
|
|
9098
|
+
_blocks.sort((a, b) => (b.best - a.best) || a.file.localeCompare(b.file));
|
|
9099
|
+
for (const blk of _blocks) {
|
|
9100
|
+
if (!jsonMode) log(`\n# ${blk.file}`);
|
|
9101
|
+
for (const h of blk.top) { if (!jsonMode) log(` L${h.line}: ${h.text}`); results.push({ file: blk.file, line: h.line, text: h.text, score: h.score }); }
|
|
9055
9102
|
}
|
|
9056
9103
|
// 1.9.25: --include-code 옵션 — 실제 소스 코드 본문도 검색 (src/tests/bin)
|
|
9057
9104
|
// 이전 모순 감지 0/5 → 5/5의 핵심 보완
|
|
@@ -9068,10 +9115,13 @@ function memorySearch(root, query) {
|
|
|
9068
9115
|
if (!/\.(js|ts|jsx|tsx|gd|cs|py|rb|go|rs|md|html|css|json)$/i.test(e.name)) continue;
|
|
9069
9116
|
let txt; try { txt = read(p); } catch { continue; }
|
|
9070
9117
|
const lines = txt.split('\n');
|
|
9071
|
-
|
|
9118
|
+
// 1.36.23: 메모리 파일 경로와 동일 처리로 통일 — 동의어 리콜 + BM25 정렬 후 limit(종전엔 단일 regex + 앞 N건 절단이라
|
|
9119
|
+
// JSON results 배열에 score 있는 항목/없는 항목이 섞였다). 21k줄 소스에서 "앞 5건"은 특히 자의적이었음.
|
|
9120
|
+
const hits = _scoreHits(query, lines.map((line, i) => ({ line: i + 1, text: line.trim().slice(0, 160), _raw: line }))
|
|
9121
|
+
.filter(x => _isHit(x._raw))).sort((a, b) => (b.score - a.score) || (a.line - b.line));
|
|
9072
9122
|
if (hits.length) {
|
|
9073
9123
|
if (!jsonMode) log(`\n# ${rel(root, p)}`);
|
|
9074
|
-
for (const h of hits.slice(0,
|
|
9124
|
+
for (const h of hits.slice(0, _limit)) { if (!jsonMode) log(` L${h.line}: ${h.text}`); results.push({ file: rel(root, p), line: h.line, text: h.text, score: h.score }); }
|
|
9075
9125
|
total += hits.length;
|
|
9076
9126
|
}
|
|
9077
9127
|
}
|
|
@@ -9079,9 +9129,22 @@ function memorySearch(root, query) {
|
|
|
9079
9129
|
walkCodeDir(dp);
|
|
9080
9130
|
}
|
|
9081
9131
|
}
|
|
9082
|
-
|
|
9083
|
-
|
|
9084
|
-
|
|
9132
|
+
// 1.36.23: 0건이면 실제 색인 어휘로 근접어 제안(지어내지 않음 — vocab 은 방금 읽은 메모리 파일에서 추출).
|
|
9133
|
+
let _suggest = [];
|
|
9134
|
+
if (total === 0) {
|
|
9135
|
+
try {
|
|
9136
|
+
const vocab = new Set();
|
|
9137
|
+
for (const f of files) {
|
|
9138
|
+
const p = path.join(root, f); if (!exists(p)) continue;
|
|
9139
|
+
for (const t of _tokenizeForRank(read(p))) if (t.length >= 2) vocab.add(t);
|
|
9140
|
+
}
|
|
9141
|
+
_suggest = _suggestTerms(query, vocab, { limit: 5 });
|
|
9142
|
+
} catch {}
|
|
9143
|
+
}
|
|
9144
|
+
// 1.36.23: total(전체 히트) 과 results.length(표시분) 불일치를 자기정합화 — 종전엔 total 16 / results 15 처럼 어긋났다.
|
|
9145
|
+
if (jsonMode) { log(JSON.stringify({ version: VERSION, query, terms: useSyn && terms.length > 1 ? terms : undefined, total, shown: results.length, truncated: total > results.length, suggestions: _suggest.length ? _suggest : undefined, includeCode: has('--include-code'), results }, null, 2)); return; }
|
|
9146
|
+
if (total === 0) { log('(no matches)'); if (_suggest.length) log(` 혹시 이건가요: ${_suggest.join(', ')}`); }
|
|
9147
|
+
else log(`\n${total} matches${total > results.length ? ` (상위 ${results.length}건 표시 — --limit 로 조절)` : ''}${has('--include-code') ? ' · 소스 코드 포함' : ''}${useSyn && terms.length > 1 ? ` · 동의어: ${terms.slice(1).join('/')}` : ''}`);
|
|
9085
9148
|
}
|
|
9086
9149
|
|
|
9087
9150
|
function handoff(root) {
|
|
@@ -12956,7 +13019,7 @@ function _banner(opts = {}) {
|
|
|
12956
13019
|
cprint(' ' + C.green(padded) + C.dim('# ' + desc));
|
|
12957
13020
|
};
|
|
12958
13021
|
|
|
12959
|
-
// 1.20.2 (UR-0010 Phase 1): 첫 화면 배너 — UI 언어(
|
|
13022
|
+
// 1.20.2 (UR-0010 Phase 1): 첫 화면 배너 — UI 언어(기본 ko, 영어 opt-in) 적용.
|
|
12960
13023
|
const L = _uiLang(arg('--path', process.cwd()));
|
|
12961
13024
|
const t = (ko, en) => (L === 'en' ? en : ko);
|
|
12962
13025
|
cprint('');
|
package/lib/catalogs.js
CHANGED
|
@@ -413,6 +413,22 @@ const MERGE_OVERWRITE_FILES = new Set([
|
|
|
413
413
|
const REQUIRED_WORKSPACE_FILES = ['.harness/plan.md', '.harness/progress-tracker.md', '.harness/guideline.md', '.harness/protected-files.md', '.harness/design-system.md', '.harness/anti-lazy-work-policy.md', '.harness/session-handoff.md', '.harness/current-state.md', 'AGENTS.md'];
|
|
414
414
|
|
|
415
415
|
// 1.9.381 (UR-0025): 키워드 추출 stopwords — handoff 자동회수 / lessons 키워드 추출 단일출처 (harness 2중 중복 제거).
|
|
416
|
+
// 1.36.23: memory search 동의어 — 같은 개념을 다른 언어로 물으면 결과가 갈리던 비대칭 해소. 실측: `handoff` 37 hits /
|
|
417
|
+
// `핸드오프` **0 hits**, `lesson` 17 / `교훈` 3. 메모리 내용은 언어가 섞이기 마련이라(코드·커밋은 영어, 결정·교훈은 모국어)
|
|
418
|
+
// 한쪽 표기만 아는 사용자/에이전트가 존재하는 기록을 못 찾았다. leerness 도메인어만 담는다(검색 대상이 .harness 메모리
|
|
419
|
+
// 표면이므로 일반 UI/CSS 어휘는 잡음). 양방향으로 적는다 — 어느 쪽으로 물어도 같은 결과.
|
|
420
|
+
const MEMORY_SYNONYMS = {
|
|
421
|
+
'회고': ['retro', 'retrospective'], retro: ['회고'], retrospective: ['회고'],
|
|
422
|
+
'교훈': ['lesson'], lesson: ['교훈'], lessons: ['교훈'],
|
|
423
|
+
'룰': ['rule'], '규칙': ['rule'], rule: ['룰', '규칙'], rules: ['룰', '규칙'],
|
|
424
|
+
'핸드오프': ['handoff'], '인수인계': ['handoff'], handoff: ['핸드오프', '인수인계'],
|
|
425
|
+
'계획': ['plan'], plan: ['계획'], '마일스톤': ['milestone'], milestone: ['마일스톤'],
|
|
426
|
+
'결정': ['decision'], decision: ['결정'], decisions: ['결정'],
|
|
427
|
+
'작업': ['task'], task: ['작업'], tasks: ['작업'],
|
|
428
|
+
'검증': ['verify', 'verification'], verify: ['검증'],
|
|
429
|
+
'보안': ['security'], security: ['보안'], '시크릿': ['secret'], secret: ['시크릿'],
|
|
430
|
+
'접근성': ['a11y', 'accessibility'], a11y: ['접근성', 'accessibility'], accessibility: ['접근성', 'a11y'],
|
|
431
|
+
};
|
|
416
432
|
const KEYWORD_STOPWORDS = new Set([
|
|
417
433
|
'이런', '저런', '하다', '하고', '있는', '하지', '에서',
|
|
418
434
|
'작업', '구현', '추가', '진행', '수정', '변경', '검토', '확인',
|
|
@@ -493,4 +509,4 @@ const _TOOL_CATALOG = {
|
|
|
493
509
|
}
|
|
494
510
|
};
|
|
495
511
|
|
|
496
|
-
module.exports = { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS };
|
|
512
|
+
module.exports = { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, MEMORY_SYNONYMS, SKILL_CATALOG_PRESETS };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// lib/search-core.js — memory search 랭킹 코어 (1.36.23). 순수함수 · 0-deps · 부작용 0.
|
|
2
|
+
// require.main 가드(1.9.255) 덕에 단위테스트 가능. 산술(Math.log)만 사용 — 외부 의존성 없음.
|
|
3
|
+
//
|
|
4
|
+
// ★ 설계 불변식 — 이 모듈은 "정렬"만 한다. "무엇이 히트냐"(리콜)는 caller 의 substring regex 가 담당.
|
|
5
|
+
// 이유: 토큰화 리콜은 **언어별 형태론 가정**을 끌어들인다. substring 은 언어 중립적이고 현 동작의 상위집합이라 안전하다.
|
|
6
|
+
// 실측 사례(교착어): `memory search "핸드오프"` 는 substring 이라 "핸드오프**를** 자동화…" 를 찾지만, 공백 토큰화 BM25 로
|
|
7
|
+
// 리콜을 대체하면 doc 토큰이 ['핸드오프를',…] 이라 쿼리와 불일치 → **지금 찾히는 문서가 0건**이 된다. 조사·굴절·합성어가
|
|
8
|
+
// 있는 어떤 언어에서도 같은 계열의 손실이 난다(독일어 합성어, 터키어 교착 등). 형태소 분석기는 의존성 0 원칙상 불가.
|
|
9
|
+
// 따라서 BM25 는 랭킹 레이어로만 두고 리콜은 substring 유지 → tokenizeForRank 가 틀려도 히트가 사라지지 않는 안전판
|
|
10
|
+
// (정렬 순서만 나빠질 뿐). 한글 prefix 변형은 그 근사치일 뿐 정확도 요구사항이 아니다.
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
// 랭킹 전용 토크나이저. 한글 토큰은 조사/어미 절단을 근사하려고 prefix 변형도 함께 방출한다
|
|
14
|
+
// ('핸드오프를' → 핸드오프를/핸드/핸드오/핸드오프) → 쿼리 '핸드오프' 가 스코어를 받는다.
|
|
15
|
+
// 상한(len<=16, prefix<=6개)으로 긴 문자열의 토큰 폭발을 막는다. 정렬 전용이라 근사로 충분.
|
|
16
|
+
function tokenizeForRank(s) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const base = String(s || '').toLowerCase().split(/[^0-9a-z가-힣_]+/i).filter(Boolean);
|
|
19
|
+
for (const t of base) {
|
|
20
|
+
out.push(t);
|
|
21
|
+
if (t.length <= 16 && /^[가-힣]+$/.test(t) && t.length >= 3) {
|
|
22
|
+
const max = Math.min(t.length - 1, 2 + 6);
|
|
23
|
+
for (let k = 2; k <= max; k++) out.push(t.slice(0, k));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 동의어 확장 — 맵을 인자로 주입받아 순수성 유지(카탈로그 의존 X). 원본을 항상 첫 요소로 둔다.
|
|
30
|
+
function expandQuery(query, synonymMap) {
|
|
31
|
+
const q = String(query || '').trim();
|
|
32
|
+
if (!q) return [];
|
|
33
|
+
const key = q.toLowerCase();
|
|
34
|
+
const out = [q];
|
|
35
|
+
for (const s of (synonymMap && synonymMap[key]) || []) {
|
|
36
|
+
if (s && String(s).toLowerCase() !== key) out.push(String(s));
|
|
37
|
+
}
|
|
38
|
+
return [...new Set(out)];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// BM25 — 히트 집합 내 상대 랭킹용. docs = hits 자체(각 라인이 1문서). 희귀어를 포함한 히트가 위로 온다.
|
|
42
|
+
// k1=1.5 / b=0.75 (표준). 히트가 1건이면 idf 가 평탄해도 순서에 영향 없음.
|
|
43
|
+
function scoreHits(query, hits, opts = {}) {
|
|
44
|
+
const k1 = opts.k1 == null ? 1.5 : opts.k1;
|
|
45
|
+
const b = opts.b == null ? 0.75 : opts.b;
|
|
46
|
+
const qTokens = [...new Set(tokenizeForRank(query))];
|
|
47
|
+
const docs = hits.map(h => tokenizeForRank((h && h.text) || ''));
|
|
48
|
+
const N = docs.length || 1;
|
|
49
|
+
const avgdl = (docs.reduce((s, d) => s + d.length, 0) / N) || 1;
|
|
50
|
+
const df = Object.create(null);
|
|
51
|
+
for (const d of docs) for (const t of new Set(d)) df[t] = (df[t] || 0) + 1;
|
|
52
|
+
return hits.map((h, i) => {
|
|
53
|
+
const d = docs[i], dl = d.length || 1;
|
|
54
|
+
const tf = Object.create(null);
|
|
55
|
+
for (const t of d) tf[t] = (tf[t] || 0) + 1;
|
|
56
|
+
let score = 0;
|
|
57
|
+
for (const q of qTokens) {
|
|
58
|
+
const f = tf[q] || 0;
|
|
59
|
+
if (!f) continue;
|
|
60
|
+
const n = df[q] || 0;
|
|
61
|
+
const idf = Math.log(1 + (N - n + 0.5) / (n + 0.5));
|
|
62
|
+
score += idf * ((f * (k1 + 1)) / (f + k1 * (1 - b + b * (dl / avgdl))));
|
|
63
|
+
}
|
|
64
|
+
return Object.assign({}, h, { score: Math.round(score * 1000) / 1000 });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 0건일 때 근접어 제안 — 실제 색인 어휘(vocab)만 근거로. 지어내지 않는다.
|
|
69
|
+
function suggestTerms(query, vocab, opts = {}) {
|
|
70
|
+
const minPrefix = opts.minPrefix || 2;
|
|
71
|
+
const limit = opts.limit || 5;
|
|
72
|
+
const q = String(query || '').toLowerCase().trim();
|
|
73
|
+
if (q.length < minPrefix || !vocab) return [];
|
|
74
|
+
const pre = q.slice(0, Math.min(3, Math.max(minPrefix, q.length)));
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const v of vocab) {
|
|
77
|
+
const lv = String(v || '').toLowerCase();
|
|
78
|
+
if (!lv || lv === q) continue;
|
|
79
|
+
if (lv.startsWith(pre) || (lv.length >= minPrefix && q.startsWith(lv.slice(0, minPrefix)))) out.push(String(v));
|
|
80
|
+
if (out.length >= limit * 4) break;
|
|
81
|
+
}
|
|
82
|
+
return [...new Set(out)].slice(0, limit);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { tokenizeForRank, expandQuery, scoreHits, suggestTerms };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.36.23",
|
|
4
|
+
"description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"leerness",
|
|
7
7
|
"ai",
|