mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
feat: learnings bin scripts — append-only JSONL read/write
gstack-learnings-log: validates JSON, auto-injects timestamp, appends to ~/.gstack/projects/$SLUG/learnings.jsonl. Append-only (no mutation). gstack-learnings-search: reads/filters/dedupes learnings with confidence decay (observed/inferred lose 1pt/30d), cross-project discovery, and "latest winner" resolution per key+type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-learnings-log — append a learning to the project learnings file
|
||||
# Usage: gstack-learnings-log '{"skill":"review","type":"pitfall","key":"n-plus-one","insight":"...","confidence":8,"source":"observed"}'
|
||||
#
|
||||
# Append-only storage. Duplicates (same key+type) are resolved at read time
|
||||
# by gstack-learnings-search ("latest winner" per key+type).
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
mkdir -p "$GSTACK_HOME/projects/$SLUG"
|
||||
|
||||
INPUT="$1"
|
||||
|
||||
# Validate: input must be parseable JSON
|
||||
if ! printf '%s' "$INPUT" | bun -e "JSON.parse(await Bun.stdin.text())" 2>/dev/null; then
|
||||
echo "gstack-learnings-log: invalid JSON, skipping" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Inject timestamp if not present
|
||||
if ! printf '%s' "$INPUT" | bun -e "const j=JSON.parse(await Bun.stdin.text()); if(!j.ts) process.exit(1)" 2>/dev/null; then
|
||||
INPUT=$(printf '%s' "$INPUT" | bun -e "
|
||||
const j = JSON.parse(await Bun.stdin.text());
|
||||
j.ts = new Date().toISOString();
|
||||
console.log(JSON.stringify(j));
|
||||
" 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
echo "$INPUT" >> "$GSTACK_HOME/projects/$SLUG/learnings.jsonl"
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-learnings-search — read and filter project learnings
|
||||
# Usage: gstack-learnings-search [--type TYPE] [--query KEYWORD] [--limit N] [--cross-project]
|
||||
#
|
||||
# Reads ~/.gstack/projects/$SLUG/learnings.jsonl, applies confidence decay,
|
||||
# resolves duplicates (latest winner per key+type), and outputs formatted text.
|
||||
# Exit 0 silently if no learnings file exists.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
|
||||
TYPE=""
|
||||
QUERY=""
|
||||
LIMIT=10
|
||||
CROSS_PROJECT=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--type) TYPE="$2"; shift 2 ;;
|
||||
--query) QUERY="$2"; shift 2 ;;
|
||||
--limit) LIMIT="$2"; shift 2 ;;
|
||||
--cross-project) CROSS_PROJECT=true; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
LEARNINGS_FILE="$GSTACK_HOME/projects/$SLUG/learnings.jsonl"
|
||||
|
||||
# Collect all JSONL files to search
|
||||
FILES=()
|
||||
[ -f "$LEARNINGS_FILE" ] && FILES+=("$LEARNINGS_FILE")
|
||||
|
||||
if [ "$CROSS_PROJECT" = true ]; then
|
||||
# Add other projects' learnings (max 5, sorted by mtime)
|
||||
for f in $(find "$GSTACK_HOME/projects" -name "learnings.jsonl" -not -path "*/$SLUG/*" 2>/dev/null | head -5); do
|
||||
FILES+=("$f")
|
||||
done
|
||||
fi
|
||||
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Process all files through bun for JSON parsing, decay, dedup, filtering
|
||||
cat "${FILES[@]}" 2>/dev/null | bun -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const now = Date.now();
|
||||
const type = '${TYPE}';
|
||||
const query = '${QUERY}'.toLowerCase();
|
||||
const limit = ${LIMIT};
|
||||
const slug = '${SLUG}';
|
||||
|
||||
const entries = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const e = JSON.parse(line);
|
||||
if (!e.key || !e.type) continue;
|
||||
|
||||
// Apply confidence decay: observed/inferred lose 1pt per 30 days
|
||||
let conf = e.confidence || 5;
|
||||
if (e.source === 'observed' || e.source === 'inferred') {
|
||||
const days = Math.floor((now - new Date(e.ts).getTime()) / 86400000);
|
||||
conf = Math.max(0, conf - Math.floor(days / 30));
|
||||
}
|
||||
e._effectiveConfidence = conf;
|
||||
|
||||
// Determine if this is from the current project or cross-project
|
||||
// Cross-project entries are tagged for display
|
||||
e._crossProject = !line.includes(slug) && '${CROSS_PROJECT}' === 'true';
|
||||
|
||||
entries.push(e);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Dedup: latest winner per key+type
|
||||
const seen = new Map();
|
||||
for (const e of entries) {
|
||||
const dk = e.key + '|' + e.type;
|
||||
const existing = seen.get(dk);
|
||||
if (!existing || new Date(e.ts) > new Date(existing.ts)) {
|
||||
seen.set(dk, e);
|
||||
}
|
||||
}
|
||||
let results = Array.from(seen.values());
|
||||
|
||||
// Filter by type
|
||||
if (type) results = results.filter(e => e.type === type);
|
||||
|
||||
// Filter by query
|
||||
if (query) results = results.filter(e =>
|
||||
(e.key || '').toLowerCase().includes(query) ||
|
||||
(e.insight || '').toLowerCase().includes(query) ||
|
||||
(e.files || []).some(f => f.toLowerCase().includes(query))
|
||||
);
|
||||
|
||||
// Sort by effective confidence desc, then recency
|
||||
results.sort((a, b) => {
|
||||
if (b._effectiveConfidence !== a._effectiveConfidence) return b._effectiveConfidence - a._effectiveConfidence;
|
||||
return new Date(b.ts).getTime() - new Date(a.ts).getTime();
|
||||
});
|
||||
|
||||
// Limit
|
||||
results = results.slice(0, limit);
|
||||
|
||||
if (results.length === 0) process.exit(0);
|
||||
|
||||
// Format output
|
||||
const byType = {};
|
||||
for (const e of results) {
|
||||
const t = e.type || 'unknown';
|
||||
if (!byType[t]) byType[t] = [];
|
||||
byType[t].push(e);
|
||||
}
|
||||
|
||||
// Summary line
|
||||
const counts = Object.entries(byType).map(([t, arr]) => arr.length + ' ' + t + (arr.length > 1 ? 's' : ''));
|
||||
console.log('LEARNINGS: ' + results.length + ' loaded (' + counts.join(', ') + ')');
|
||||
console.log('');
|
||||
|
||||
for (const [t, arr] of Object.entries(byType)) {
|
||||
console.log('## ' + t.charAt(0).toUpperCase() + t.slice(1) + 's');
|
||||
for (const e of arr) {
|
||||
const cross = e._crossProject ? ' [cross-project]' : '';
|
||||
const files = e.files?.length ? ' (files: ' + e.files.join(', ') + ')' : '';
|
||||
console.log('- [' + e.key + '] (confidence: ' + e._effectiveConfidence + '/10, ' + e.source + ', ' + (e.ts || '').split('T')[0] + ')' + cross);
|
||||
console.log(' ' + e.insight + files);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
" 2>/dev/null || exit 0
|
||||
Reference in New Issue
Block a user