hypomnema 1.1.0 → 1.2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/commands/audit.md +2 -2
- package/commands/crystallize.md +113 -23
- package/commands/feedback.md +40 -26
- package/commands/ingest.md +31 -9
- package/commands/upgrade.md +2 -2
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONTRIBUTING.md +1 -1
- package/hooks/hooks.json +30 -1
- package/hooks/hypo-auto-commit.mjs +10 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +4 -3
- package/hooks/hypo-compact-guard.mjs +33 -24
- package/hooks/hypo-cwd-change.mjs +107 -24
- package/hooks/hypo-file-watch.mjs +23 -10
- package/hooks/hypo-first-prompt.mjs +37 -23
- package/hooks/hypo-hot-rebuild.mjs +22 -10
- package/hooks/hypo-lookup.mjs +171 -65
- package/hooks/hypo-personal-check.mjs +207 -112
- package/hooks/hypo-pre-commit.mjs +46 -0
- package/hooks/hypo-session-end.mjs +58 -0
- package/hooks/hypo-session-record.mjs +11 -5
- package/hooks/hypo-session-start.mjs +298 -52
- package/hooks/hypo-shared.mjs +793 -37
- package/hooks/hypo-web-fetch-ingest.mjs +121 -0
- package/hooks/version-check-fetch.mjs +74 -0
- package/hooks/version-check.mjs +184 -0
- package/package.json +17 -3
- package/scripts/crystallize.mjs +623 -18
- package/scripts/doctor.mjs +730 -47
- package/scripts/feedback-sync.mjs +974 -0
- package/scripts/feedback.mjs +253 -44
- package/scripts/graph.mjs +35 -22
- package/scripts/ingest.mjs +89 -16
- package/scripts/init.mjs +398 -113
- package/scripts/lib/design-history-stale.mjs +83 -0
- package/scripts/lib/extensions.mjs +749 -0
- package/scripts/lib/frontmatter.mjs +5 -1
- package/scripts/lib/hypo-ignore.mjs +12 -10
- package/scripts/lib/pkg-json.mjs +23 -5
- package/scripts/lib/project-create.mjs +225 -0
- package/scripts/lib/schema-vocab.mjs +96 -0
- package/scripts/lint.mjs +238 -31
- package/scripts/query.mjs +26 -10
- package/scripts/resume.mjs +11 -5
- package/scripts/session-audit.mjs +37 -27
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +363 -49
- package/scripts/upgrade.mjs +706 -202
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +59 -25
- package/skills/crystallize/SKILL.md +20 -7
- package/skills/ingest/SKILL.md +25 -5
- package/templates/.hypoignore +16 -2
- package/templates/Home.md +2 -0
- package/templates/SCHEMA.md +61 -6
- package/templates/extensions/agents/.gitkeep +0 -0
- package/templates/extensions/commands/.gitkeep +0 -0
- package/templates/extensions/hooks/.gitkeep +0 -0
- package/templates/extensions/skills/.gitkeep +0 -0
- package/templates/gitignore +5 -0
- package/templates/hot.md +2 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +42 -2
- package/templates/hypo-help.md +1 -1
- package/templates/pages/observability/_index.md +77 -0
- package/templates/projects/_template/index.md +2 -2
- package/templates/projects/_template/prd.md +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hypo-web-fetch-ingest.mjs — PostToolUse hook (fix #2)
|
|
4
|
+
*
|
|
5
|
+
* When the LLM uses WebFetch or WebSearch, nudge it to follow §8.4 path A:
|
|
6
|
+
* "external research → /hypo:ingest into sources/". This hook produces a
|
|
7
|
+
* *signal*, not an automatic ingest call — per spec §5.4.6 Q-5.4.6:
|
|
8
|
+
* "WebFetch/WebSearch 자동 ingest 시그널은 별도 hook(fix #2)으로 분리."
|
|
9
|
+
*
|
|
10
|
+
* Why signal-only (not direct ingest):
|
|
11
|
+
* - slug derivation is LLM-driven (commands/ingest.md), so a hook cannot
|
|
12
|
+
* reliably write to the canonical sources/<slug>.* path.
|
|
13
|
+
* - duplicate-ingest detection is delegated to /hypo:ingest itself; the
|
|
14
|
+
* nudge text says "이미 반영 여부 확인 후" so the LLM checks the page
|
|
15
|
+
* graph before adding a new source.
|
|
16
|
+
*
|
|
17
|
+
* Output contract — Claude Code docs, "Add context for Claude":
|
|
18
|
+
* PostToolUse uses **nested** hookSpecificOutput.additionalContext, NOT
|
|
19
|
+
* the top-level additionalContext that UserPromptSubmit hooks use.
|
|
20
|
+
* buildOutput() in hypo-shared.mjs is the top-level helper used by
|
|
21
|
+
* hypo-first-prompt / hypo-lookup; intentionally not reused here. See
|
|
22
|
+
* commit 515458f for the per-event matrix this codebase follows.
|
|
23
|
+
*
|
|
24
|
+
* URL redaction:
|
|
25
|
+
* additionalContext lands in the transcript. Query strings and
|
|
26
|
+
* fragments may carry tokens (?token=…, #access_token=…), so we emit
|
|
27
|
+
* origin + pathname only. Malformed URLs are dropped to avoid raw
|
|
28
|
+
* echoes.
|
|
29
|
+
*
|
|
30
|
+
* matcher choice:
|
|
31
|
+
* hooks/hooks.json does not (today) propagate matcher/timeout through
|
|
32
|
+
* scripts/init.mjs:mergeSettingsJson — the installer rewrites groups
|
|
33
|
+
* as {hooks:[{type,command}]}. So we filter on tool_name internally
|
|
34
|
+
* instead of declaring a matcher. Per-tool spawn cost (~39ms node
|
|
35
|
+
* startup, measured 2026-05-23) is the trade-off; switch to a matcher
|
|
36
|
+
* when the installer learns to preserve it.
|
|
37
|
+
*
|
|
38
|
+
* Fail-safe (spec §5.4.3 (e)): silent fail + {continue:true,
|
|
39
|
+
* suppressOutput:true}. PostToolUse fires only on successful tool calls;
|
|
40
|
+
* failures arrive on a separate PostToolUseFailure event, so no
|
|
41
|
+
* in-hook failure branch is needed.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { isGateSkipped } from './hypo-shared.mjs';
|
|
45
|
+
|
|
46
|
+
let input = {};
|
|
47
|
+
try {
|
|
48
|
+
const raw = await new Promise((r) => {
|
|
49
|
+
let d = '';
|
|
50
|
+
process.stdin.on('data', (c) => (d += c));
|
|
51
|
+
process.stdin.on('end', () => r(d));
|
|
52
|
+
});
|
|
53
|
+
input = raw ? JSON.parse(raw) : {};
|
|
54
|
+
} catch (err) {
|
|
55
|
+
process.stderr.write(`[hypo-web-fetch-ingest] error: ${err?.message ?? String(err)}\n`);
|
|
56
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const context = buildContext(input);
|
|
61
|
+
const output = { continue: true, suppressOutput: true };
|
|
62
|
+
if (context) {
|
|
63
|
+
output.hookSpecificOutput = {
|
|
64
|
+
hookEventName: 'PostToolUse',
|
|
65
|
+
additionalContext: context,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
console.log(JSON.stringify(output));
|
|
69
|
+
|
|
70
|
+
function buildContext(data) {
|
|
71
|
+
if (isGateSkipped()) return null;
|
|
72
|
+
const tool = data?.tool_name ?? '';
|
|
73
|
+
if (tool === 'WebFetch') {
|
|
74
|
+
const url = redactUrl(data?.tool_input?.url);
|
|
75
|
+
if (!url) return null;
|
|
76
|
+
return (
|
|
77
|
+
`[WIKI AUTO-INGEST: WebFetch] ${url}\n` +
|
|
78
|
+
`Spec §8.4 path A: confirm this URL is not already represented in ` +
|
|
79
|
+
`sources/ or pages/, then call /hypo:ingest to capture it. ` +
|
|
80
|
+
`(URL redacted of query/hash for transcript safety.)`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (tool === 'WebSearch') {
|
|
84
|
+
return (
|
|
85
|
+
`[WIKI AUTO-INGEST: WebSearch] external search performed.\n` +
|
|
86
|
+
`Spec §8.4 path A: any URL you fetch from these results should be ` +
|
|
87
|
+
`captured via /hypo:ingest — confirm it is not already in sources/ before ingesting.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reduce a URL to `origin + pathname` so query strings, fragments, and
|
|
95
|
+
* userinfo (`https://user:pass@host`) — which routinely carry
|
|
96
|
+
* credentials / session tokens — never reach the transcript via
|
|
97
|
+
* additionalContext. `new URL().origin` is protocol + host + port only,
|
|
98
|
+
* so userinfo is dropped for free.
|
|
99
|
+
*
|
|
100
|
+
* The protocol allow-list (http/https) is a defense-in-depth guard:
|
|
101
|
+
* non-web schemes like `file://`, `ftp://`, `data:` can produce
|
|
102
|
+
* surprising origins (e.g. `null/Users/...`) or pull a local path into
|
|
103
|
+
* the transcript, so they are rejected instead of redacted.
|
|
104
|
+
*
|
|
105
|
+
* Path-carried secrets (`https://host/path/<token>`) remain — keeping
|
|
106
|
+
* the path is the whole point of identifying *which* page to ingest, so
|
|
107
|
+
* full path redaction would defeat the nudge's purpose. The nudge text
|
|
108
|
+
* still tells the LLM to verify the URL before committing it.
|
|
109
|
+
*
|
|
110
|
+
* Returns null on malformed input or disallowed scheme.
|
|
111
|
+
*/
|
|
112
|
+
function redactUrl(raw) {
|
|
113
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
114
|
+
try {
|
|
115
|
+
const u = new URL(raw);
|
|
116
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
117
|
+
return `${u.origin}${u.pathname}`;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* version-check-fetch.mjs — detached update-check worker
|
|
4
|
+
*
|
|
5
|
+
* Spawned (detached, unref'd, stdio ignored) by the SessionStart hook ONLY when
|
|
6
|
+
* the cache is stale. It fetches the latest published versions for both
|
|
7
|
+
* channels and merges them into the cache, then exits. The hook never waits on
|
|
8
|
+
* it, so session start stays at 0ms added latency; the refreshed version is
|
|
9
|
+
* shown from the NEXT session.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort throughout: any failure (offline, 404, timeout) leaves the cache
|
|
12
|
+
* untouched for that channel and exits 0 — never throws back at a hook.
|
|
13
|
+
*
|
|
14
|
+
* Usage: node version-check-fetch.mjs [cachePath]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { defaultCachePath, mergeLatest } from './version-check.mjs';
|
|
18
|
+
|
|
19
|
+
const NPM_URL = 'https://registry.npmjs.org/hypomnema/latest';
|
|
20
|
+
const PLUGIN_URL =
|
|
21
|
+
'https://raw.githubusercontent.com/sk-lim19f/Hypomnema/main/.claude-plugin/marketplace.json';
|
|
22
|
+
const TIMEOUT_MS = 2000;
|
|
23
|
+
|
|
24
|
+
async function fetchJson(url) {
|
|
25
|
+
const ctrl = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(url, { signal: ctrl.signal, headers: { accept: 'application/json' } });
|
|
29
|
+
if (!res.ok) return null;
|
|
30
|
+
return await res.json();
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
} finally {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function fetchNpmLatest() {
|
|
39
|
+
const data = await fetchJson(NPM_URL);
|
|
40
|
+
return data && typeof data.version === 'string' ? data.version : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchPluginLatest() {
|
|
44
|
+
const data = await fetchJson(PLUGIN_URL);
|
|
45
|
+
if (!data || !Array.isArray(data.plugins)) return null;
|
|
46
|
+
// Select by name rather than plugins[0]: a future marketplace.json could list
|
|
47
|
+
// more than one plugin or reorder entries, which would otherwise read the
|
|
48
|
+
// wrong version.
|
|
49
|
+
const entry = data.plugins.find((p) => p && p.name === 'hypomnema');
|
|
50
|
+
const v = entry && entry.version;
|
|
51
|
+
return typeof v === 'string' ? v : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function main() {
|
|
55
|
+
const cachePath = process.argv[2] || defaultCachePath();
|
|
56
|
+
const [npmLatest, pluginLatest] = await Promise.all([fetchNpmLatest(), fetchPluginLatest()]);
|
|
57
|
+
|
|
58
|
+
const latest = {};
|
|
59
|
+
if (npmLatest) latest.npm = npmLatest;
|
|
60
|
+
if (pluginLatest) latest.plugin = pluginLatest;
|
|
61
|
+
|
|
62
|
+
// Even if both fetches fail we still stamp checkedAt so we don't hammer the
|
|
63
|
+
// network every single session while offline — the TTL backs off naturally.
|
|
64
|
+
try {
|
|
65
|
+
mergeLatest(cachePath, latest);
|
|
66
|
+
} catch {
|
|
67
|
+
/* best-effort */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().then(
|
|
72
|
+
() => process.exit(0),
|
|
73
|
+
() => process.exit(0),
|
|
74
|
+
);
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* version-check.mjs — update-notifier core (pure logic + cache I/O)
|
|
3
|
+
*
|
|
4
|
+
* Hypomnema ships through TWO channels — the `hypomnema` npm package and a
|
|
5
|
+
* Claude Code plugin (marketplace `sk-lim19f/Hypomnema`). This module decides,
|
|
6
|
+
* given the cached "latest" versions and the installed version, whether to show
|
|
7
|
+
* an "update available" banner at session start.
|
|
8
|
+
*
|
|
9
|
+
* Design constraints (see ADR / teams review 2026-05-21):
|
|
10
|
+
* - The SessionStart hook must never make a synchronous network call. It reads
|
|
11
|
+
* ONLY the cache here; a detached worker (version-check-fetch.mjs) refreshes
|
|
12
|
+
* the cache out-of-band. So everything in this file is offline + cheap.
|
|
13
|
+
* - Per-channel state: npm and plugin `latest` can diverge (npm publish vs
|
|
14
|
+
* marketplace commit happen at different times), so `latest` and
|
|
15
|
+
* `notifiedFor` are keyed by channel — a single scalar would suppress or
|
|
16
|
+
* repeat banners when the user switches channels.
|
|
17
|
+
* - Cache writes are atomic (tmp + rename); the fetch worker MERGES rather
|
|
18
|
+
* than overwrites so it never erases the hook's `notifiedFor` marks.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
|
22
|
+
import { dirname, join } from 'path';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
|
|
25
|
+
export const TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
26
|
+
export const CHANNELS = ['npm', 'plugin'];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cache lives under ~/.claude (Claude-hook-specific state), NOT inside the
|
|
30
|
+
* Obsidian vault ~/hypomnema — that directory is git-tracked, so a cache file
|
|
31
|
+
* there would create dirty status, sync noise, and accidental-commit / privacy
|
|
32
|
+
* risk (teams review (e), 2026-05-21).
|
|
33
|
+
*/
|
|
34
|
+
export function defaultCachePath(home = homedir()) {
|
|
35
|
+
return join(home, '.claude', 'hypomnema', 'cache', 'version-check.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── semver ───────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a semver string. Tolerates a leading `v` and ignores build metadata.
|
|
42
|
+
* Returns null for anything that isn't `MAJOR.MINOR.PATCH[-prerelease][+build]`.
|
|
43
|
+
*/
|
|
44
|
+
export function parseSemver(v) {
|
|
45
|
+
if (typeof v !== 'string') return null;
|
|
46
|
+
const m = v
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/^v/, '')
|
|
49
|
+
.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
50
|
+
if (!m) return null;
|
|
51
|
+
return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || '' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compare two semver strings. Returns -1 / 0 / 1, or null if either is invalid.
|
|
56
|
+
* A release outranks a prerelease of the same x.y.z (1.2.3 > 1.2.3-rc.1).
|
|
57
|
+
*/
|
|
58
|
+
export function compareSemver(a, b) {
|
|
59
|
+
const pa = parseSemver(a);
|
|
60
|
+
const pb = parseSemver(b);
|
|
61
|
+
if (!pa || !pb) return null;
|
|
62
|
+
for (const k of ['major', 'minor', 'patch']) {
|
|
63
|
+
if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
|
|
64
|
+
}
|
|
65
|
+
if (pa.pre === pb.pre) return 0;
|
|
66
|
+
if (!pa.pre) return 1; // release > prerelease
|
|
67
|
+
if (!pb.pre) return -1;
|
|
68
|
+
return pa.pre < pb.pre ? -1 : 1; // lexicographic fallback (good enough)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── channel detection ──────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decide the active install channel from the package root path.
|
|
75
|
+
*
|
|
76
|
+
* The caller should pass the root derived from the RUNNING hook path
|
|
77
|
+
* (import.meta.url → ../..), with ~/.claude/hypo-pkg.json's pkgRoot only as a
|
|
78
|
+
* fallback: that metadata file has drifted before, and in a dual install
|
|
79
|
+
* (npm global + plugin) it names just one path. Reporting the inactive channel
|
|
80
|
+
* would hand the user the wrong update command (teams review (b), 2026-05-21).
|
|
81
|
+
*
|
|
82
|
+
* Plugin is checked before npm because a plugin install can itself live under a
|
|
83
|
+
* node_modules path, but never vice-versa.
|
|
84
|
+
*/
|
|
85
|
+
export function detectChannel(pkgRoot) {
|
|
86
|
+
if (typeof pkgRoot !== 'string' || !pkgRoot) return 'unknown';
|
|
87
|
+
const p = pkgRoot.replace(/\\/g, '/');
|
|
88
|
+
if (p.includes('/plugins/') || p.includes('/.claude/plugins/')) return 'plugin';
|
|
89
|
+
if (p.includes('/node_modules/')) return 'npm';
|
|
90
|
+
return 'unknown';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Channel-specific one-line update instruction. */
|
|
94
|
+
export function buildUpdateLine(channel, current, latest) {
|
|
95
|
+
const head = `[Hypomnema] Update available! ${current} → ${latest}`;
|
|
96
|
+
if (channel === 'plugin') {
|
|
97
|
+
return `${head}\n → run: /plugin marketplace update hypomnema then /reload-plugins`;
|
|
98
|
+
}
|
|
99
|
+
if (channel === 'npm') {
|
|
100
|
+
return `${head}\n → run: npm install -g hypomnema`;
|
|
101
|
+
}
|
|
102
|
+
return `${head}\n → npm i -g hypomnema (or /plugin marketplace update hypomnema && /reload-plugins)`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── cache freshness + notice decision (pure) ─────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Is the cache fresh enough to skip a refresh? A `checkedAt` in the future
|
|
109
|
+
* (clock skew / corrupt cache) is treated as stale so the worker re-fetches.
|
|
110
|
+
*/
|
|
111
|
+
export function cacheIsFresh(cache, now = Date.now(), ttl = TTL_MS) {
|
|
112
|
+
if (!cache || typeof cache.checkedAt !== 'number') return false;
|
|
113
|
+
if (cache.checkedAt > now + 60_000) return false;
|
|
114
|
+
return now - cache.checkedAt < ttl;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Decide whether to show a banner. Returns { latest, line } or null.
|
|
119
|
+
* Skips when: unknown channel, no cached latest for the channel, invalid
|
|
120
|
+
* semver, current >= latest (incl. local dev where current > latest), or the
|
|
121
|
+
* channel was already notified for this exact latest version.
|
|
122
|
+
*/
|
|
123
|
+
export function computeNotice(cache, channel, current) {
|
|
124
|
+
if (!cache || channel === 'unknown' || !CHANNELS.includes(channel)) return null;
|
|
125
|
+
const latest = cache.latest && cache.latest[channel];
|
|
126
|
+
if (!latest) return null;
|
|
127
|
+
const cmp = compareSemver(current, latest);
|
|
128
|
+
if (cmp === null || cmp >= 0) return null;
|
|
129
|
+
const already = cache.notifiedFor && cache.notifiedFor[channel];
|
|
130
|
+
if (already === latest) return null;
|
|
131
|
+
return { latest, line: buildUpdateLine(channel, current, latest) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── cache I/O (atomic) ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/** Read + parse the cache; returns null on missing/corrupt file. */
|
|
137
|
+
export function readCache(path) {
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Atomic write: tmp file in the same dir, then rename (last-writer-wins). */
|
|
146
|
+
export function writeCacheAtomic(path, obj) {
|
|
147
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
148
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
149
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
|
150
|
+
renameSync(tmp, path);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Record that the banner for {channel: latest} has been shown, preserving the
|
|
155
|
+
* rest of the cache. Read-merge-write so concurrent worker refreshes and hook
|
|
156
|
+
* marks don't clobber each other's fields. Best-effort: swallows errors.
|
|
157
|
+
*/
|
|
158
|
+
export function markNotified(path, channel, latest) {
|
|
159
|
+
try {
|
|
160
|
+
const cache = readCache(path) || {};
|
|
161
|
+
cache.notifiedFor = { ...(cache.notifiedFor || {}), [channel]: latest };
|
|
162
|
+
writeCacheAtomic(path, cache);
|
|
163
|
+
} catch {
|
|
164
|
+
/* best-effort */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Merge freshly-fetched latest versions into the cache without erasing
|
|
170
|
+
* `notifiedFor`. Used by the detached fetch worker.
|
|
171
|
+
*/
|
|
172
|
+
export function mergeLatest(path, latest, now = Date.now()) {
|
|
173
|
+
const cache = readCache(path) || {};
|
|
174
|
+
cache.checkedAt = now;
|
|
175
|
+
cache.latest = { ...(cache.latest || {}), ...latest };
|
|
176
|
+
cache.notifiedFor = cache.notifiedFor || {};
|
|
177
|
+
writeCacheAtomic(path, cache);
|
|
178
|
+
return cache;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** True if any opt-out env var is set. */
|
|
182
|
+
export function isOptedOut(env = process.env) {
|
|
183
|
+
return Boolean(env.HYPO_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,7 +17,14 @@
|
|
|
17
17
|
".claude-plugin/",
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
|
-
"keywords": [
|
|
20
|
+
"keywords": [
|
|
21
|
+
"wiki",
|
|
22
|
+
"llm",
|
|
23
|
+
"claude",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"personal-wiki",
|
|
26
|
+
"knowledge-management"
|
|
27
|
+
],
|
|
21
28
|
"repository": {
|
|
22
29
|
"type": "git",
|
|
23
30
|
"url": "https://github.com/sk-lim19f/Hypomnema.git"
|
|
@@ -32,6 +39,13 @@
|
|
|
32
39
|
"scripts": {
|
|
33
40
|
"test": "node tests/runner.mjs",
|
|
34
41
|
"lint": "node scripts/lint.mjs",
|
|
35
|
-
"graph": "node scripts/graph.mjs"
|
|
42
|
+
"graph": "node scripts/graph.mjs",
|
|
43
|
+
"smoke-pack": "node scripts/smoke-pack.mjs",
|
|
44
|
+
"format": "prettier --write .",
|
|
45
|
+
"format:check": "prettier --check .",
|
|
46
|
+
"prepublishOnly": "npm test && npm run lint && npm run smoke-pack"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"prettier": "^3.8.3"
|
|
36
50
|
}
|
|
37
51
|
}
|