sanook-cli 0.4.0 → 0.5.0
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/.env.example +19 -0
- package/CHANGELOG.md +144 -0
- package/README.md +153 -20
- package/README.th.md +136 -0
- package/dist/agentContext.js +4 -0
- package/dist/approval.js +6 -0
- package/dist/bin.js +394 -51
- package/dist/brain.js +92 -59
- package/dist/brand.js +47 -0
- package/dist/checkpoint.js +37 -0
- package/dist/commands.js +86 -6
- package/dist/compaction.js +76 -5
- package/dist/config.js +100 -12
- package/dist/cost.js +60 -3
- package/dist/doctor.js +92 -0
- package/dist/gateway/auth.js +2 -2
- package/dist/gateway/ledger.js +2 -2
- package/dist/gateway/scheduler.js +1 -0
- package/dist/gateway/serve.js +6 -4
- package/dist/gateway/server.js +10 -2
- package/dist/git.js +11 -2
- package/dist/hooks.js +43 -17
- package/dist/knowledge.js +48 -49
- package/dist/loop.js +182 -66
- package/dist/lsp/client.js +173 -0
- package/dist/lsp/framing.js +56 -0
- package/dist/lsp/index.js +138 -0
- package/dist/lsp/servers.js +82 -0
- package/dist/mcp-server.js +244 -0
- package/dist/mcp.js +184 -29
- package/dist/memory-store.js +559 -0
- package/dist/memory.js +143 -29
- package/dist/orchestrate.js +150 -0
- package/dist/providers/codex.js +2 -2
- package/dist/providers/keys.js +3 -2
- package/dist/providers/registry.js +133 -1
- package/dist/repomap.js +93 -0
- package/dist/search/chunk.js +158 -0
- package/dist/search/embed-store.js +187 -0
- package/dist/search/engine.js +203 -0
- package/dist/search/fuse.js +35 -0
- package/dist/search/index-core.js +187 -0
- package/dist/search/indexer.js +241 -0
- package/dist/search/store.js +77 -0
- package/dist/session.js +42 -8
- package/dist/skill-install.js +10 -10
- package/dist/skills.js +12 -9
- package/dist/summarize.js +31 -0
- package/dist/tools/bash.js +21 -2
- package/dist/tools/diagnostics.js +41 -0
- package/dist/tools/edit.js +29 -7
- package/dist/tools/index.js +8 -1
- package/dist/tools/list.js +7 -2
- package/dist/tools/permission.js +90 -9
- package/dist/tools/read.js +23 -4
- package/dist/tools/remember.js +1 -1
- package/dist/tools/sandbox.js +61 -0
- package/dist/tools/search.js +105 -4
- package/dist/tools/task.js +195 -29
- package/dist/tools/timeout.js +35 -0
- package/dist/tools/util.js +10 -0
- package/dist/tools/write.js +6 -4
- package/dist/trust.js +89 -0
- package/dist/ui/app.js +218 -27
- package/dist/ui/banner.js +4 -9
- package/dist/ui/history.js +30 -0
- package/dist/ui/mentions.js +44 -0
- package/dist/ui/setup.js +6 -5
- package/dist/ui/useEditor.js +83 -0
- package/dist/update.js +114 -0
- package/dist/worktree.js +173 -0
- package/package.json +11 -5
- package/scripts/postinstall.mjs +33 -0
- package/second-brain/.agents/_Index.md +30 -0
- package/second-brain/.agents/skills/_Index.md +30 -0
- package/second-brain/.agents/workflows/_Index.md +30 -0
- package/second-brain/AGENTS.md +4 -4
- package/second-brain/Acceptance/_Index.md +30 -0
- package/second-brain/Acceptance/golden-case-template.md +39 -0
- package/second-brain/Areas/_Index.md +30 -0
- package/second-brain/Bugs/System-OS/_Index.md +30 -0
- package/second-brain/Bugs/_Index.md +30 -0
- package/second-brain/CLAUDE.md +4 -1
- package/second-brain/Checklists/_Index.md +30 -0
- package/second-brain/Checklists/preflight-postflight-template.md +29 -0
- package/second-brain/Distillations/_Index.md +30 -0
- package/second-brain/Entities/_Index.md +30 -0
- package/second-brain/Entities/entity-template.md +33 -0
- package/second-brain/Evals/_Index.md +30 -0
- package/second-brain/Evals/correction-pairs.md +24 -0
- package/second-brain/Evals/failure-taxonomy.md +24 -0
- package/second-brain/Evals/golden-set.md +25 -0
- package/second-brain/Evals/quality-ledger.md +23 -0
- package/second-brain/Evals/self-eval-rubric.md +23 -0
- package/second-brain/GEMINI.md +4 -4
- package/second-brain/Goals/_Index.md +30 -0
- package/second-brain/Handoffs/_Index.md +30 -0
- package/second-brain/Home.md +7 -0
- package/second-brain/Intake/Raw Sources/_Index.md +30 -0
- package/second-brain/Intake/_Index.md +30 -0
- package/second-brain/Intake/_Quarantine/_Index.md +30 -0
- package/second-brain/Learning/_Index.md +30 -0
- package/second-brain/Playbooks/_Index.md +30 -0
- package/second-brain/Playbooks/playbook-template.md +23 -0
- package/second-brain/Projects/_Index.md +30 -0
- package/second-brain/Prompts/_Index.md +30 -0
- package/second-brain/README.md +2 -1
- package/second-brain/Research/_Index.md +30 -0
- package/second-brain/Retrospectives/_Index.md +30 -0
- package/second-brain/Reviews/_Index.md +30 -0
- package/second-brain/Runbooks/_Index.md +30 -0
- package/second-brain/Runbooks/eval-loop.md +24 -0
- package/second-brain/Sessions/_Index.md +30 -0
- package/second-brain/Shared/AI-Context-Index.md +20 -0
- package/second-brain/Shared/AI-Threads/_Index.md +30 -0
- package/second-brain/Shared/Archive/_Index.md +30 -0
- package/second-brain/Shared/Assets/_Index.md +30 -0
- package/second-brain/Shared/Context-Packs/_Index.md +30 -0
- package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
- package/second-brain/Shared/Coordination/NOW.md +28 -0
- package/second-brain/Shared/Coordination/_Index.md +30 -0
- package/second-brain/Shared/Coordination/agent-registry.md +24 -0
- package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
- package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
- package/second-brain/Shared/Coordination/task-board.md +32 -0
- package/second-brain/Shared/Core-Facts/_Index.md +30 -0
- package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
- package/second-brain/Shared/Glossary/_Index.md +30 -0
- package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
- package/second-brain/Shared/Operating-State/_Index.md +30 -0
- package/second-brain/Shared/Prompting/_Index.md +30 -0
- package/second-brain/Shared/Provenance/_Index.md +30 -0
- package/second-brain/Shared/Rules/_Index.md +30 -0
- package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
- package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
- package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
- package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
- package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
- package/second-brain/Shared/Rules/rules-formatting.md +34 -0
- package/second-brain/Shared/Scripts/_Index.md +30 -0
- package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
- package/second-brain/Shared/User-Memory/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
- package/second-brain/Shared/Working-Memory/_Index.md +30 -0
- package/second-brain/Shared/_Index.md +30 -0
- package/second-brain/Shared/mcp-servers/_Index.md +30 -0
- package/second-brain/Skills/_Index.md +30 -0
- package/second-brain/Templates/_Index.md +30 -0
- package/second-brain/Templates/bug.md +2 -0
- package/second-brain/Templates/handoff.md +2 -0
- package/second-brain/Templates/session.md +2 -0
- package/second-brain/Tools/_Index.md +30 -0
- package/second-brain/Traces/_Index.md +30 -0
- package/second-brain/Vault Structure Map.md +33 -1
- package/second-brain/copilot/_Index.md +30 -0
- package/skills/audit-license-compliance/SKILL.md +117 -0
- package/skills/author-codemod/SKILL.md +110 -0
- package/skills/build-audit-logging/SKILL.md +112 -0
- package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
- package/skills/build-cli-tool/SKILL.md +108 -0
- package/skills/build-data-table/SKILL.md +141 -0
- package/skills/build-native-mobile-ui/SKILL.md +154 -0
- package/skills/build-offline-first-sync/SKILL.md +118 -0
- package/skills/build-realtime-channel/SKILL.md +122 -0
- package/skills/build-vector-search/SKILL.md +131 -0
- package/skills/compose-local-dev-stack/SKILL.md +149 -0
- package/skills/configure-bundler-build/SKILL.md +166 -0
- package/skills/configure-dns-tls/SKILL.md +142 -0
- package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
- package/skills/configure-security-headers-csp/SKILL.md +122 -0
- package/skills/contract-testing/SKILL.md +140 -0
- package/skills/datetime-timezone-correctness/SKILL.md +125 -0
- package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
- package/skills/debug-flaky-tests/SKILL.md +128 -0
- package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
- package/skills/deliver-webhooks/SKILL.md +116 -0
- package/skills/design-api-pagination/SKILL.md +144 -0
- package/skills/design-authorization-model/SKILL.md +119 -0
- package/skills/design-backup-dr-recovery/SKILL.md +113 -0
- package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
- package/skills/design-multi-tenancy/SKILL.md +100 -0
- package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
- package/skills/design-relational-schema/SKILL.md +129 -0
- package/skills/design-search-index-infra/SKILL.md +151 -0
- package/skills/design-state-machine/SKILL.md +108 -0
- package/skills/design-token-system/SKILL.md +109 -0
- package/skills/distributed-locks-leases/SKILL.md +120 -0
- package/skills/encrypt-sensitive-data/SKILL.md +148 -0
- package/skills/feature-flags-rollout/SKILL.md +130 -0
- package/skills/file-upload-object-storage/SKILL.md +107 -0
- package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
- package/skills/harden-llm-app-reliability/SKILL.md +126 -0
- package/skills/i18n-localization-setup/SKILL.md +113 -0
- package/skills/idempotency-keys/SKILL.md +107 -0
- package/skills/implement-push-notifications/SKILL.md +142 -0
- package/skills/ingest-webhook-secure/SKILL.md +120 -0
- package/skills/integrate-oauth-oidc/SKILL.md +126 -0
- package/skills/load-stress-test/SKILL.md +129 -0
- package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
- package/skills/model-nosql-data/SKILL.md +118 -0
- package/skills/money-decimal-arithmetic/SKILL.md +123 -0
- package/skills/monitor-ml-drift/SKILL.md +109 -0
- package/skills/numeric-precision-units/SKILL.md +144 -0
- package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
- package/skills/optimize-react-rerenders/SKILL.md +124 -0
- package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
- package/skills/payments-billing-integration/SKILL.md +114 -0
- package/skills/pin-toolchain-versions/SKILL.md +116 -0
- package/skills/plan-strangler-migration/SKILL.md +95 -0
- package/skills/property-based-testing/SKILL.md +108 -0
- package/skills/publish-package-registry/SKILL.md +130 -0
- package/skills/recover-git-state/SKILL.md +119 -0
- package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
- package/skills/resilience-timeouts-retries/SKILL.md +104 -0
- package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
- package/skills/rewrite-git-history/SKILL.md +109 -0
- package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
- package/skills/schema-evolution-compatibility/SKILL.md +121 -0
- package/skills/send-transactional-email/SKILL.md +126 -0
- package/skills/serve-deploy-ml-model/SKILL.md +107 -0
- package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
- package/skills/setup-devcontainer-env/SKILL.md +131 -0
- package/skills/setup-lint-format-precommit/SKILL.md +140 -0
- package/skills/setup-monorepo-tooling/SKILL.md +125 -0
- package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
- package/skills/structured-output-llm/SKILL.md +86 -0
- package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
- package/skills/test-data-factories/SKILL.md +158 -0
- package/skills/threat-model-stride/SKILL.md +123 -0
- package/skills/train-evaluate-ml-model/SKILL.md +109 -0
- package/skills/unicode-text-correctness/SKILL.md +109 -0
- package/skills/visual-regression-testing/SKILL.md +120 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// src/search/index-core.ts — the zero-dependency search FLOOR.
|
|
3
|
+
//
|
|
4
|
+
// A pure-TS inverted index with REAL BM25 (k1=1.2, b=0.75, genuine corpus-stat
|
|
5
|
+
// IDF via df/N). No SQLite, no Bun, no native binary, no network — it works the
|
|
6
|
+
// instant a corpus exists, on any OS Node 22 runs on. This is deliberately NOT
|
|
7
|
+
// node:sqlite FTS5: that is experimental, its FTS5 build is not guaranteed across
|
|
8
|
+
// platforms, and it reintroduces a quasi-native dependency that fights the
|
|
9
|
+
// zero-config/portability contract. A few hundred lines of TS give us a real
|
|
10
|
+
// ranking model that FTS5's bm25() only approximates without true global IDF.
|
|
11
|
+
//
|
|
12
|
+
// Tokenization REUSES memory-store.ts normalize() (the canonical, Thai-safe,
|
|
13
|
+
// stopword-aware tokenizer) so memory matching and search matching never drift.
|
|
14
|
+
//
|
|
15
|
+
// addDoc/removeDoc MUTATE the index in place and return it — an index over a
|
|
16
|
+
// large vault must not deep-copy its postings map on every chunk (that is the
|
|
17
|
+
// one place we diverge from memory-store's small-array immutability). bm25Search
|
|
18
|
+
// is pure and read-only. Re-adding the same doc id replaces its postings, so the
|
|
19
|
+
// index can never accumulate duplicate postings the way arra's FTS5
|
|
20
|
+
// delete-then-insert can drift.
|
|
21
|
+
// ============================================================================
|
|
22
|
+
import { normalize } from '../memory-store.js';
|
|
23
|
+
export const SEARCH_SOURCES = ['memory', 'vault', 'session', 'skill'];
|
|
24
|
+
/** BM25 params — Robertson/Spärck-Jones defaults; title terms get weighted tf. */
|
|
25
|
+
const K1 = 1.2;
|
|
26
|
+
const B = 0.75;
|
|
27
|
+
const TITLE_BOOST = 2; // a term in a doc's title counts this many times toward tf
|
|
28
|
+
const WORD_SEG = new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
29
|
+
export const INDEX_VERSION = 1;
|
|
30
|
+
export function emptyIndex() {
|
|
31
|
+
return { version: INDEX_VERSION, postings: new Map(), docs: new Map(), totalDl: 0 };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Ordered tokens WITH repeats — BM25 needs term frequencies, so unlike
|
|
35
|
+
* memory-store's tokens() (a deduped Set) we keep counts. Builds on the SAME
|
|
36
|
+
* canonical normalize() (lowercase, punctuation→space, Thai preserved), then
|
|
37
|
+
* segments with Intl.Segmenter at word granularity so Thai (which has no spaces)
|
|
38
|
+
* splits into real words instead of one coarse blob, giving BM25 genuine Thai
|
|
39
|
+
* term frequencies while preserving repeats.
|
|
40
|
+
*/
|
|
41
|
+
export function termList(text) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const seg of WORD_SEG.segment(normalize(text))) {
|
|
44
|
+
if (!seg.isWordLike)
|
|
45
|
+
continue;
|
|
46
|
+
const token = seg.segment.trim();
|
|
47
|
+
if (token.length > 1)
|
|
48
|
+
out.push(token);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** combined term-frequency map for a doc, with title terms weighted, + the token length. */
|
|
53
|
+
function termFreqs(title, text) {
|
|
54
|
+
const tf = new Map();
|
|
55
|
+
const body = termList(text);
|
|
56
|
+
const head = termList(title);
|
|
57
|
+
for (const t of body)
|
|
58
|
+
tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
59
|
+
for (const t of head)
|
|
60
|
+
tf.set(t, (tf.get(t) ?? 0) + TITLE_BOOST);
|
|
61
|
+
return { tf, dl: body.length + head.length };
|
|
62
|
+
}
|
|
63
|
+
/** add (or REPLACE, if id already present) a document. Mutates + returns idx. */
|
|
64
|
+
export function addDoc(idx, doc) {
|
|
65
|
+
if (idx.docs.has(doc.id))
|
|
66
|
+
removeDoc(idx, doc.id); // replace → no posting creep
|
|
67
|
+
const { tf, dl } = termFreqs(doc.title, doc.text);
|
|
68
|
+
const meta = {
|
|
69
|
+
id: doc.id,
|
|
70
|
+
source: doc.source,
|
|
71
|
+
title: doc.title,
|
|
72
|
+
text: doc.text,
|
|
73
|
+
path: doc.path,
|
|
74
|
+
noteType: doc.noteType,
|
|
75
|
+
tags: doc.tags ?? [],
|
|
76
|
+
links: doc.links ?? [],
|
|
77
|
+
importance: doc.importance,
|
|
78
|
+
updatedMs: doc.updatedMs,
|
|
79
|
+
dl,
|
|
80
|
+
};
|
|
81
|
+
idx.docs.set(doc.id, meta);
|
|
82
|
+
idx.totalDl += dl;
|
|
83
|
+
for (const [term, freq] of tf) {
|
|
84
|
+
const plist = idx.postings.get(term);
|
|
85
|
+
if (plist)
|
|
86
|
+
plist.push({ docId: doc.id, tf: freq });
|
|
87
|
+
else
|
|
88
|
+
idx.postings.set(term, [{ docId: doc.id, tf: freq }]);
|
|
89
|
+
}
|
|
90
|
+
return idx;
|
|
91
|
+
}
|
|
92
|
+
/** remove a document and all its postings. Mutates + returns idx. No-op if absent. */
|
|
93
|
+
export function removeDoc(idx, id) {
|
|
94
|
+
const meta = idx.docs.get(id);
|
|
95
|
+
if (!meta)
|
|
96
|
+
return idx;
|
|
97
|
+
const { tf } = termFreqs(meta.title, meta.text);
|
|
98
|
+
for (const term of tf.keys()) {
|
|
99
|
+
const plist = idx.postings.get(term);
|
|
100
|
+
if (!plist)
|
|
101
|
+
continue;
|
|
102
|
+
const next = plist.filter((p) => p.docId !== id);
|
|
103
|
+
if (next.length)
|
|
104
|
+
idx.postings.set(term, next);
|
|
105
|
+
else
|
|
106
|
+
idx.postings.delete(term);
|
|
107
|
+
}
|
|
108
|
+
idx.totalDl -= meta.dl;
|
|
109
|
+
idx.docs.delete(id);
|
|
110
|
+
return idx;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* BM25 ranking — pure, read-only. Genuine IDF from df/N (the always-positive
|
|
114
|
+
* BM25+ form ln(1 + (N-df+0.5)/(df+0.5))), length-normalized by avgdl. Optional
|
|
115
|
+
* source allow-list keeps cross-corpus queries cheap. Deterministic tie-break by id.
|
|
116
|
+
*/
|
|
117
|
+
export function bm25Search(idx, query, limit = 50, sources) {
|
|
118
|
+
const n = idx.docs.size;
|
|
119
|
+
if (!n)
|
|
120
|
+
return [];
|
|
121
|
+
const avgdl = idx.totalDl / n || 1;
|
|
122
|
+
const qTerms = [...new Set(termList(query))];
|
|
123
|
+
if (!qTerms.length)
|
|
124
|
+
return [];
|
|
125
|
+
const scores = new Map();
|
|
126
|
+
for (const term of qTerms) {
|
|
127
|
+
const plist = idx.postings.get(term);
|
|
128
|
+
if (!plist)
|
|
129
|
+
continue;
|
|
130
|
+
const df = plist.length;
|
|
131
|
+
const idf = Math.log(1 + (n - df + 0.5) / (df + 0.5));
|
|
132
|
+
for (const p of plist) {
|
|
133
|
+
const meta = idx.docs.get(p.docId);
|
|
134
|
+
if (!meta)
|
|
135
|
+
continue;
|
|
136
|
+
if (sources && !sources.has(meta.source))
|
|
137
|
+
continue;
|
|
138
|
+
const denom = p.tf + K1 * (1 - B + B * (meta.dl / avgdl));
|
|
139
|
+
const contrib = idf * ((p.tf * (K1 + 1)) / denom);
|
|
140
|
+
scores.set(p.docId, (scores.get(p.docId) ?? 0) + contrib);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return [...scores.entries()]
|
|
144
|
+
.map(([id, score]) => ({ id, score }))
|
|
145
|
+
.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
146
|
+
.slice(0, limit);
|
|
147
|
+
}
|
|
148
|
+
/** remove every doc of a given source (used to refresh the live memory/session/skill corpora). Returns count removed. */
|
|
149
|
+
export function removeSource(idx, source) {
|
|
150
|
+
const ids = [];
|
|
151
|
+
for (const m of idx.docs.values())
|
|
152
|
+
if (m.source === source)
|
|
153
|
+
ids.push(m.id);
|
|
154
|
+
for (const id of ids)
|
|
155
|
+
removeDoc(idx, id);
|
|
156
|
+
return ids.length;
|
|
157
|
+
}
|
|
158
|
+
export function indexStats(idx) {
|
|
159
|
+
const bySource = {};
|
|
160
|
+
for (const m of idx.docs.values())
|
|
161
|
+
bySource[m.source] = (bySource[m.source] ?? 0) + 1;
|
|
162
|
+
return {
|
|
163
|
+
docs: idx.docs.size,
|
|
164
|
+
terms: idx.postings.size,
|
|
165
|
+
bySource,
|
|
166
|
+
avgdl: idx.docs.size ? idx.totalDl / idx.docs.size : 0,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export function indexToJSON(idx) {
|
|
170
|
+
const postings = {};
|
|
171
|
+
for (const [term, plist] of idx.postings)
|
|
172
|
+
postings[term] = plist;
|
|
173
|
+
return { version: idx.version, totalDl: idx.totalDl, postings, docs: [...idx.docs.values()] };
|
|
174
|
+
}
|
|
175
|
+
export function indexFromJSON(raw) {
|
|
176
|
+
const obj = raw;
|
|
177
|
+
if (!obj || obj.version !== INDEX_VERSION || !obj.postings || !Array.isArray(obj.docs)) {
|
|
178
|
+
return emptyIndex(); // unknown/old shape degrades to empty rather than throwing
|
|
179
|
+
}
|
|
180
|
+
const idx = emptyIndex();
|
|
181
|
+
idx.totalDl = obj.totalDl ?? 0;
|
|
182
|
+
for (const [term, plist] of Object.entries(obj.postings))
|
|
183
|
+
idx.postings.set(term, plist);
|
|
184
|
+
for (const m of obj.docs)
|
|
185
|
+
idx.docs.set(m.id, m);
|
|
186
|
+
return idx;
|
|
187
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// src/search/indexer.ts — incremental, O(delta) vault indexer.
|
|
3
|
+
//
|
|
4
|
+
// Beats arra-oracle's indexer on three axes:
|
|
5
|
+
// 1. NO directory convention. arra requires a `ψ/memory/…` tree; we index the
|
|
6
|
+
// user's EXISTING second-brain vault via getBrainPath(), any layout.
|
|
7
|
+
// 2. TRUE incremental. arra full-re-indexes every pass (guarded only by a >50%
|
|
8
|
+
// delete abort). We diff a per-file manifest: an unchanged file costs ONE
|
|
9
|
+
// stat(); only changed files are read+sha256+re-chunked; deleted files have
|
|
10
|
+
// their chunks evicted precisely (manifest stores each file's chunk ids).
|
|
11
|
+
// 3. ONE unified surface. Vault chunks, active memory Facts, recent session
|
|
12
|
+
// turns, and skills all land in the SAME ranked index — the unification arra
|
|
13
|
+
// never did (its memory store and search index use divorced formats).
|
|
14
|
+
//
|
|
15
|
+
// The file-walk is injected (VaultFS) so the core logic unit-tests against an
|
|
16
|
+
// in-memory fs + clock with zero disk, exactly like memory-store.ts.
|
|
17
|
+
// ============================================================================
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { appHomePath } from '../brand.js';
|
|
22
|
+
import { getBrainPath } from '../memory.js';
|
|
23
|
+
import { loadSkills } from '../skills.js';
|
|
24
|
+
import { activeFacts, effImportance, loadStore } from '../memory-store.js';
|
|
25
|
+
import { chunkMarkdown } from './chunk.js';
|
|
26
|
+
import { addDoc, removeDoc, removeSource } from './index-core.js';
|
|
27
|
+
import { loadIndex, saveIndex } from './store.js';
|
|
28
|
+
/** strip a .md path to a human title fallback when a chunk has no heading. */
|
|
29
|
+
function fileTitle(rel) {
|
|
30
|
+
return (rel.split('/').pop() ?? rel).replace(/\.md$/i, '');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Incremental vault pass. Mutates `index`, returns the NEXT manifest + a diff.
|
|
34
|
+
* Pure w.r.t. the injected fs/clock — no disk access of its own.
|
|
35
|
+
*/
|
|
36
|
+
export async function indexVaultFiles(index, manifest, fs) {
|
|
37
|
+
const next = {};
|
|
38
|
+
const diff = { added: 0, updated: 0, removed: 0, skipped: 0 };
|
|
39
|
+
const paths = await fs.listMarkdown();
|
|
40
|
+
const seenExisting = new Set();
|
|
41
|
+
for (const rel of paths) {
|
|
42
|
+
const fp = await fs.fingerprint(rel);
|
|
43
|
+
if (!fp)
|
|
44
|
+
continue; // vanished between listing and stat → treat as deletion below
|
|
45
|
+
seenExisting.add(rel);
|
|
46
|
+
const prev = manifest[rel];
|
|
47
|
+
// cheap path: mtime + size unchanged ⇒ skip without reading the file
|
|
48
|
+
if (prev && prev.mtimeMs === fp.mtimeMs && prev.size === fp.size) {
|
|
49
|
+
next[rel] = prev;
|
|
50
|
+
diff.skipped++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const content = await fs.read(rel);
|
|
54
|
+
const sha = fs.hash(content);
|
|
55
|
+
// touched but content identical (mtime bumped by a sync) ⇒ refresh fingerprint, keep chunks
|
|
56
|
+
if (prev && prev.sha === sha) {
|
|
57
|
+
next[rel] = { ...prev, mtimeMs: fp.mtimeMs, size: fp.size };
|
|
58
|
+
diff.skipped++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// changed or new ⇒ evict old chunks, re-chunk, re-add
|
|
62
|
+
if (prev)
|
|
63
|
+
for (const id of prev.ids)
|
|
64
|
+
removeDoc(index, id);
|
|
65
|
+
const parsed = chunkMarkdown(rel, content);
|
|
66
|
+
const title0 = fileTitle(rel);
|
|
67
|
+
const ids = [];
|
|
68
|
+
for (const c of parsed.chunks) {
|
|
69
|
+
const doc = {
|
|
70
|
+
id: c.id,
|
|
71
|
+
source: 'vault',
|
|
72
|
+
title: c.heading || title0,
|
|
73
|
+
text: c.text,
|
|
74
|
+
path: rel,
|
|
75
|
+
noteType: parsed.frontmatter.noteType,
|
|
76
|
+
tags: parsed.frontmatter.tags,
|
|
77
|
+
links: parsed.links,
|
|
78
|
+
updatedMs: fp.mtimeMs,
|
|
79
|
+
};
|
|
80
|
+
addDoc(index, doc);
|
|
81
|
+
ids.push(c.id);
|
|
82
|
+
}
|
|
83
|
+
next[rel] = { mtimeMs: fp.mtimeMs, size: fp.size, sha, ids };
|
|
84
|
+
if (prev)
|
|
85
|
+
diff.updated++;
|
|
86
|
+
else
|
|
87
|
+
diff.added++;
|
|
88
|
+
}
|
|
89
|
+
// deletions: present last time, absent now ⇒ evict their chunks
|
|
90
|
+
for (const rel of Object.keys(manifest)) {
|
|
91
|
+
if (seenExisting.has(rel))
|
|
92
|
+
continue;
|
|
93
|
+
for (const id of manifest[rel].ids)
|
|
94
|
+
removeDoc(index, id);
|
|
95
|
+
diff.removed++;
|
|
96
|
+
}
|
|
97
|
+
return { manifest: next, diff };
|
|
98
|
+
}
|
|
99
|
+
/** refresh the live memory corpus: drop old memory docs, re-add active Facts with an importance prior. */
|
|
100
|
+
export function foldFacts(index, facts, now) {
|
|
101
|
+
removeSource(index, 'memory');
|
|
102
|
+
const searchable = facts.filter((f) => f.status === 'active' && f.tier !== 'inbox');
|
|
103
|
+
for (const f of searchable) {
|
|
104
|
+
addDoc(index, {
|
|
105
|
+
id: f.id, // memory-store deriveId — stable, dedups against itself
|
|
106
|
+
source: 'memory',
|
|
107
|
+
title: '',
|
|
108
|
+
text: f.text,
|
|
109
|
+
noteType: f.noteType,
|
|
110
|
+
tags: f.tags,
|
|
111
|
+
importance: effImportance(f, now),
|
|
112
|
+
updatedMs: f.updated,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return searchable.length;
|
|
116
|
+
}
|
|
117
|
+
/** refresh the session corpus (first-user-message per recent session). */
|
|
118
|
+
export function foldSessions(index, sessions) {
|
|
119
|
+
removeSource(index, 'session');
|
|
120
|
+
for (const s of sessions) {
|
|
121
|
+
addDoc(index, { id: s.id, source: 'session', title: '', text: s.text, updatedMs: s.updatedMs });
|
|
122
|
+
}
|
|
123
|
+
return sessions.length;
|
|
124
|
+
}
|
|
125
|
+
/** refresh the skill corpus (name + description + whenToUse). */
|
|
126
|
+
export function foldSkills(index, skills) {
|
|
127
|
+
removeSource(index, 'skill');
|
|
128
|
+
for (const s of skills) {
|
|
129
|
+
addDoc(index, { id: s.id, source: 'skill', title: s.name, text: s.text });
|
|
130
|
+
}
|
|
131
|
+
return skills.length;
|
|
132
|
+
}
|
|
133
|
+
// ---- real-filesystem wiring ------------------------------------------------
|
|
134
|
+
const IGNORE_DIRS = new Set([
|
|
135
|
+
'node_modules', 'dist', 'build', 'coverage', '.next', '.cache', '.git',
|
|
136
|
+
'.obsidian', 'vendor', '.turbo', '.vercel',
|
|
137
|
+
]);
|
|
138
|
+
/** node:fs implementation of VaultFS — recursive .md walk with the default-ignore set. */
|
|
139
|
+
export function nodeVaultFS(root) {
|
|
140
|
+
async function walk(dir, rel, out) {
|
|
141
|
+
let entries;
|
|
142
|
+
try {
|
|
143
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const e of entries) {
|
|
149
|
+
if (e.isDirectory()) {
|
|
150
|
+
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.'))
|
|
151
|
+
continue;
|
|
152
|
+
await walk(join(dir, e.name), rel ? `${rel}/${e.name}` : e.name, out);
|
|
153
|
+
}
|
|
154
|
+
else if (e.isFile() && e.name.toLowerCase().endsWith('.md')) {
|
|
155
|
+
out.push(rel ? `${rel}/${e.name}` : e.name);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
async listMarkdown() {
|
|
161
|
+
const out = [];
|
|
162
|
+
await walk(root, '', out);
|
|
163
|
+
return out.sort();
|
|
164
|
+
},
|
|
165
|
+
async fingerprint(relPath) {
|
|
166
|
+
try {
|
|
167
|
+
const s = await stat(join(root, relPath));
|
|
168
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
read: (relPath) => readFile(join(root, relPath), 'utf8'),
|
|
175
|
+
hash: (content) => createHash('sha256').update(content).digest('hex'),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const SESSIONS_DIR = appHomePath('sessions');
|
|
179
|
+
/** load first-user-message of the most recent sessions (bounded) for the session corpus. */
|
|
180
|
+
export async function loadRecentSessions(limit = 60) {
|
|
181
|
+
const out = [];
|
|
182
|
+
let candidates;
|
|
183
|
+
try {
|
|
184
|
+
const files = (await readdir(SESSIONS_DIR)).filter((f) => f.endsWith('.json'));
|
|
185
|
+
const withStats = await Promise.all(files.map(async (file) => {
|
|
186
|
+
const full = join(SESSIONS_DIR, file);
|
|
187
|
+
try {
|
|
188
|
+
return { file, full, mtimeMs: (await stat(full)).mtimeMs };
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}));
|
|
194
|
+
candidates = withStats
|
|
195
|
+
.filter((c) => c !== null)
|
|
196
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || b.file.localeCompare(a.file))
|
|
197
|
+
.slice(0, limit);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
for (const { file, full, mtimeMs } of candidates) {
|
|
203
|
+
try {
|
|
204
|
+
const s = JSON.parse(await readFile(full, 'utf8'));
|
|
205
|
+
const firstUser = (s.messages ?? []).find((m) => m.role === 'user');
|
|
206
|
+
const text = typeof firstUser?.content === 'string' ? firstUser.content : '';
|
|
207
|
+
if (!text.trim())
|
|
208
|
+
continue;
|
|
209
|
+
out.push({ id: `sess:${s.id ?? file}`, text: text.slice(0, 2000), updatedMs: mtimeMs });
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* skip a corrupt session file */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Full incremental reindex: vault (via getBrainPath) + memory + sessions + skills,
|
|
219
|
+
* persisted atomically. Returns a change report. This is what `sanook index` and
|
|
220
|
+
* the MCP `sanook_index` tool call.
|
|
221
|
+
*/
|
|
222
|
+
export async function reindex(now = Date.now()) {
|
|
223
|
+
const { index, manifest } = await loadIndex();
|
|
224
|
+
let diff = { added: 0, updated: 0, removed: 0, skipped: 0 };
|
|
225
|
+
let nextManifest = manifest;
|
|
226
|
+
const brain = await getBrainPath();
|
|
227
|
+
if (brain) {
|
|
228
|
+
const r = await indexVaultFiles(index, manifest, nodeVaultFS(brain));
|
|
229
|
+
nextManifest = r.manifest;
|
|
230
|
+
diff = r.diff;
|
|
231
|
+
}
|
|
232
|
+
const memory = foldFacts(index, activeFacts(await loadStore(now)), now);
|
|
233
|
+
const sessions = foldSessions(index, await loadRecentSessions());
|
|
234
|
+
const skills = foldSkills(index, (await loadSkills()).map((s) => ({
|
|
235
|
+
id: `skill:${s.name}`,
|
|
236
|
+
name: s.name,
|
|
237
|
+
text: `${s.description} ${s.whenToUse ?? ''}`.trim(),
|
|
238
|
+
})));
|
|
239
|
+
await saveIndex(index, nextManifest);
|
|
240
|
+
return { ...diff, memory, sessions, skills, vaultPath: brain ?? null };
|
|
241
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// src/search/store.ts — the ONLY disk-touching module of the search subsystem.
|
|
3
|
+
//
|
|
4
|
+
// Mirrors memory-store.ts's FS discipline exactly: atomic tmp+rename writes,
|
|
5
|
+
// 0o600 permissions, honors persistenceEnabled(), and loadIndex() NEVER writes
|
|
6
|
+
// (read paths stay pure). The persisted payload is one JSON file next to
|
|
7
|
+
// memory.json under ~/.sanook/search/index.json — no SQLite file, no native db.
|
|
8
|
+
// Vectors live in their own sidecar (embed-store.ts) so the BM25 floor never
|
|
9
|
+
// pays to read them.
|
|
10
|
+
// ============================================================================
|
|
11
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { appHomePath, persistenceEnabled } from '../brand.js';
|
|
15
|
+
import { emptyIndex, indexFromJSON, indexToJSON, } from './index-core.js';
|
|
16
|
+
export const SEARCH_DIR = appHomePath('search');
|
|
17
|
+
export const INDEX_PATH = join(SEARCH_DIR, 'index.json');
|
|
18
|
+
const FILE_VERSION = 1;
|
|
19
|
+
async function pathExists(p) {
|
|
20
|
+
try {
|
|
21
|
+
await stat(p);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** mtime of the on-disk index (ms) or 0 if absent — lets the engine cache-invalidate cheaply. */
|
|
29
|
+
export async function indexMtimeMs() {
|
|
30
|
+
try {
|
|
31
|
+
return (await stat(INDEX_PATH)).mtimeMs;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Load the persisted index + manifest. Pure read: a missing or malformed file
|
|
39
|
+
* degrades to an empty index rather than throwing, so a corrupt cache never
|
|
40
|
+
* bricks search — the next index() rebuilds it.
|
|
41
|
+
*/
|
|
42
|
+
export async function loadIndex() {
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(await readFile(INDEX_PATH, 'utf8'));
|
|
45
|
+
if (raw && raw.v === FILE_VERSION) {
|
|
46
|
+
return { index: indexFromJSON(raw.index), manifest: raw.manifest ?? {} };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* no file yet, or malformed → fall through to empty */
|
|
51
|
+
}
|
|
52
|
+
return { index: emptyIndex(), manifest: {} };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Persist index + manifest atomically (tmp+rename), 0o600. No-op when persistence
|
|
56
|
+
* is disabled (the in-memory index still works for the process, just uncached).
|
|
57
|
+
*/
|
|
58
|
+
export async function saveIndex(index, manifest) {
|
|
59
|
+
if (!persistenceEnabled())
|
|
60
|
+
return;
|
|
61
|
+
await mkdir(SEARCH_DIR, { recursive: true });
|
|
62
|
+
const payload = { v: FILE_VERSION, index: indexToJSON(index), manifest };
|
|
63
|
+
const tmp = join(SEARCH_DIR, `index.${randomUUID()}.tmp`);
|
|
64
|
+
try {
|
|
65
|
+
await writeFile(tmp, `${JSON.stringify(payload)}\n`, { mode: 0o600 });
|
|
66
|
+
await chmod(tmp, 0o600).catch(() => { });
|
|
67
|
+
await rename(tmp, INDEX_PATH);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
await rm(tmp, { force: true }).catch(() => { });
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** true if a persisted index already exists on disk. */
|
|
75
|
+
export function hasIndex() {
|
|
76
|
+
return pathExists(INDEX_PATH);
|
|
77
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { chmod, readFile, writeFile, mkdir, readdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { appHomePath, persistenceEnabled } from './brand.js';
|
|
4
|
+
import { redactKey } from './providers/keys.js';
|
|
4
5
|
// session store — จำ conversation + ความคืบหน้า เพื่อ "ทำงานต่อได้" ไม่ลืมว่าทำถึงไหน
|
|
5
|
-
const SESSION_DIR =
|
|
6
|
+
const SESSION_DIR = appHomePath('sessions');
|
|
6
7
|
export function newSessionId() {
|
|
7
8
|
// CLI runtime — ใช้ Date/random ได้ (ไม่ใช่ workflow context)
|
|
8
9
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
9
10
|
return `${ts}-${Math.random().toString(36).slice(2, 8)}`;
|
|
10
11
|
}
|
|
12
|
+
function redactUnknown(value) {
|
|
13
|
+
if (typeof value === 'string')
|
|
14
|
+
return redactKey(value);
|
|
15
|
+
if (Array.isArray(value))
|
|
16
|
+
return value.map(redactUnknown);
|
|
17
|
+
if (value && typeof value === 'object') {
|
|
18
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactUnknown(v)]));
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function sanitizeSession(s) {
|
|
23
|
+
return {
|
|
24
|
+
...s,
|
|
25
|
+
messages: redactUnknown(s.messages),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function canonicalPath(path) {
|
|
29
|
+
try {
|
|
30
|
+
return await realpath(path);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return resolve(path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
11
36
|
export async function saveSession(s) {
|
|
37
|
+
if (!persistenceEnabled())
|
|
38
|
+
return;
|
|
12
39
|
await mkdir(SESSION_DIR, { recursive: true });
|
|
13
|
-
|
|
40
|
+
const path = join(SESSION_DIR, `${s.id}.json`);
|
|
41
|
+
await writeFile(path, `${JSON.stringify(sanitizeSession(s), null, 2)}\n`, { mode: 0o600 });
|
|
42
|
+
await chmod(path, 0o600).catch(() => { });
|
|
14
43
|
}
|
|
15
44
|
export async function loadSession(id) {
|
|
16
45
|
try {
|
|
@@ -20,13 +49,18 @@ export async function loadSession(id) {
|
|
|
20
49
|
return null;
|
|
21
50
|
}
|
|
22
51
|
}
|
|
23
|
-
/** session ล่าสุด (สำหรับ --continue) */
|
|
24
|
-
export async function latestSession() {
|
|
52
|
+
/** session ล่าสุด (สำหรับ --continue). ค่า default จำกัดเฉพาะ cwd ปัจจุบัน กัน context ข้าม project */
|
|
53
|
+
export async function latestSession(cwd = process.cwd()) {
|
|
25
54
|
try {
|
|
26
55
|
const ids = (await readdir(SESSION_DIR)).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5));
|
|
27
56
|
if (!ids.length)
|
|
28
57
|
return null;
|
|
29
|
-
|
|
58
|
+
let sessions = (await Promise.all(ids.map(loadSession))).filter((s) => s !== null);
|
|
59
|
+
if (cwd) {
|
|
60
|
+
const current = await canonicalPath(cwd);
|
|
61
|
+
const pairs = await Promise.all(sessions.map(async (s) => ({ session: s, cwd: await canonicalPath(s.cwd) })));
|
|
62
|
+
sessions = pairs.filter((p) => p.cwd === current).map((p) => p.session);
|
|
63
|
+
}
|
|
30
64
|
sessions.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
31
65
|
return sessions[0] ?? null;
|
|
32
66
|
}
|
package/dist/skill-install.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, readdir, rm, stat, lstat, copyFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join, basename, resolve, sep, dirname } from 'node:path';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
@@ -7,8 +7,9 @@ import { randomUUID } from 'node:crypto';
|
|
|
7
7
|
import { lookup } from 'node:dns/promises';
|
|
8
8
|
import { isIP } from 'node:net';
|
|
9
9
|
import { parseFrontmatter, isValidSkillName } from './skills.js';
|
|
10
|
+
import { appHomePath, BRAND } from './brand.js';
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
|
-
const USER_SKILLS =
|
|
12
|
+
const USER_SKILLS = appHomePath('skills');
|
|
12
13
|
const MAX_FILES = 300;
|
|
13
14
|
const MAX_BYTES = 20 * 1024 * 1024; // 20MB ต่อ skill
|
|
14
15
|
const MAX_MD = 2 * 1024 * 1024; // 2MB ต่อ SKILL.md จาก URL
|
|
@@ -75,6 +76,7 @@ async function installFromContent(content, fallbackName) {
|
|
|
75
76
|
if (!isValidSkillName(name))
|
|
76
77
|
throw new Error(`ชื่อ skill ไม่ถูกต้อง: "${name}"`);
|
|
77
78
|
const dest = join(USER_SKILLS, name);
|
|
79
|
+
await rm(dest, { recursive: true, force: true });
|
|
78
80
|
await mkdir(dest, { recursive: true });
|
|
79
81
|
await writeFile(join(dest, 'SKILL.md'), content);
|
|
80
82
|
return { name, path: dest };
|
|
@@ -109,7 +111,7 @@ async function installFromLocal(path, onLog) {
|
|
|
109
111
|
}
|
|
110
112
|
/** clone GitHub repo (shallow) → ติดตั้ง skill — subPath ต้องอยู่ใต้ clone dir (กัน traversal escape) */
|
|
111
113
|
async function installFromGitHub(repoUrl, subPath, onLog) {
|
|
112
|
-
const tmp = join(tmpdir(),
|
|
114
|
+
const tmp = join(tmpdir(), `${BRAND.skillTempPrefix}${randomUUID().slice(0, 8)}`);
|
|
113
115
|
try {
|
|
114
116
|
onLog?.(`clone ${repoUrl} …`);
|
|
115
117
|
// execFile (no shell) + '--' กัน url ขึ้นต้น '-' ถูกตีเป็น git option + timeout
|
|
@@ -138,14 +140,12 @@ async function fetchSkillMd(url) {
|
|
|
138
140
|
if (u.protocol !== 'https:')
|
|
139
141
|
throw new Error('รองรับเฉพาะ https สำหรับ URL ของ SKILL.md');
|
|
140
142
|
// resolve hostname → block private/loopback IP (กัน SSRF ยิง internal/cloud-metadata)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
ip = res?.address ?? '';
|
|
145
|
-
}
|
|
146
|
-
if (!ip || PRIVATE_IP.test(ip))
|
|
143
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
144
|
+
const ips = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((r) => r.address);
|
|
145
|
+
if (!ips.length || ips.some((ip) => PRIVATE_IP.test(ip))) {
|
|
147
146
|
throw new Error(`URL ชี้ไป internal/private address — ปฏิเสธ (${u.hostname})`);
|
|
148
|
-
|
|
147
|
+
}
|
|
148
|
+
const r = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
149
149
|
if (!r.ok)
|
|
150
150
|
throw new Error(`fetch ไม่สำเร็จ: HTTP ${r.status}`);
|
|
151
151
|
if (Number(r.headers.get('content-length') ?? 0) > MAX_MD)
|